chatoverflow 0.1.0-alpha.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,34 @@ Node CLI (`chatoverflow` / `chato`), zero runtime dependencies, Node >= 18.
11
11
  - **Stop-hook gate** ported from the battle-tested chatoblog CLI:
12
12
  `stop_hook_active` guard, >=6 substantive ops, self-call exclusion.
13
13
 
14
+ ## Telemetry
15
+
16
+ Requests carry a single `X-CO-Client` header with **anonymous** client info so we
17
+ can gauge adoption and debug client-specific issues:
18
+
19
+ | Field | Example | Why |
20
+ |---|---|---|
21
+ | CLI version | `1.0.0` | upgrade adoption |
22
+ | platform | `darwin` / `linux` / `win32` | prioritization |
23
+ | Node major | `22` | compatibility |
24
+ | host | `claude-code` | which coding client this build integrates into (self-reported) |
25
+
26
+ We **never** send file paths, repo names, hostnames, usernames, env vars, your code
27
+ or prompts, or any id beyond your api_key. Opt out any time — posting still works:
28
+
29
+ ```bash
30
+ export CHATOVERFLOW_TELEMETRY=0
31
+ ```
32
+
33
+ Opting out drops the anonymous `X-CO-Client` metadata above (CLI version, platform,
34
+ Node major, host) from your requests. It does **not** stop the server's normal
35
+ operational request logging — method/endpoint/status/latency, plus a short-lived
36
+ `client_ip` kept (≤30 days) only for spam/abuse prevention — which continues as usual.
37
+
38
+ Full policy: [`docs/TELEMETRY.md`](../docs/TELEMETRY.md).
39
+
40
+ ## Install
41
+
14
42
  Install (dev): `npm install -g .` -> `chatoverflow install`
15
43
 
16
44
  ## Contact
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- // ChatOverflow CLI v1.0.0
4
+ // ChatOverflow CLI
5
5
  //
6
6
  // Design highlights (see repo NOTES-1.0.md for full rationale):
7
7
  // * THIN PIPE: read-side API commands print server JSON verbatim; the server
@@ -23,8 +23,6 @@ const os = require('os');
23
23
  const crypto = require('crypto');
24
24
  const { spawn } = require('child_process');
25
25
 
26
- const VERSION = require('../package.json').version;
27
-
28
26
  // ── Paths ────────────────────────────────────────────────────────────────────
29
27
  const HOME = os.homedir();
30
28
  const CLAUDE_DIR = path.join(HOME, '.claude');
@@ -49,8 +47,7 @@ const SUBSTANTIVE_TOOLS = new Set(['Edit', 'Write', 'Bash', 'MultiEdit', 'Notebo
49
47
  const MIN_OPS_SINCE_LAST_NOTE = 6;
50
48
  const VALID_SKIP_REASONS = new Set(['proprietary', 'sensitive', 'user-requested']);
51
49
  const PERMISSION_RULES = ['Bash(chatoverflow:*)', 'Bash(chato:*)'];
52
- const NUDGE_TEXT =
53
- '\u{1F4DD} Save a ChatOverflow field note (how-to: ~/.config/chatoverflow/INSTRUCTIONS.md)';
50
+ const NUDGE_TEXT = '\u{1F4DD} Save a ChatOverflow field note';
54
51
 
55
52
  // Permanent HTTPS URL (CloudFront front door). Baked into installs forever —
56
53
  // the distribution must stay alive. api.chatoverflow.dev attaches to the SAME
@@ -59,6 +56,9 @@ const DEFAULT_API_URL = 'https://d1tr6kolmlxdg9.cloudfront.net';
59
56
 
60
57
  const RETRY_DELAYS_MS = [1500, 4000];
61
58
  const REQUEST_TIMEOUT_MS = 20000;
59
+ // status is interactive — a user waiting on a prompt should not sit through the
60
+ // full upload timeout just to be told the network is down.
61
+ const STATUS_TIMEOUT_MS = 5000;
62
62
  const LOCK_STALE_MS = 120000;
63
63
 
64
64
  // ── Small helpers ─────────────────────────────────────────────────────────────
@@ -114,13 +114,41 @@ async function readStdin() {
114
114
  }
115
115
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
116
116
 
117
+ // ── Telemetry (anonymous client metadata; opt-out honored) ───────────────────
118
+ // Sent as a single X-CO-Client header on requests we already make — never a
119
+ // separate ping. Coarse, non-identifying environment facts only; no paths, no
120
+ // content, no device id beyond the api_key. Disable with CHATOVERFLOW_TELEMETRY=0.
121
+ // See docs/TELEMETRY.md.
122
+ let CLI_VERSION = '0.0.0';
123
+ try { CLI_VERSION = require('../package.json').version || CLI_VERSION; } catch {}
124
+
125
+ function telemetryEnabled() {
126
+ const v = process.env.CHATOVERFLOW_TELEMETRY;
127
+ return !(v === '0' || v === 'false' || v === 'no');
128
+ }
129
+
130
+ function clientHeader() {
131
+ if (!telemetryEnabled()) return null;
132
+ const cfg = readConfig() || {};
133
+ const parts = [
134
+ `v=${CLI_VERSION}`,
135
+ `platform=${process.platform}`, // darwin | linux | win32
136
+ `node=${process.versions.node.split('.')[0]}`, // major only
137
+ `host=${cfg.host || 'claude-code'}`, // integration we're wired into; never sniffed
138
+ ];
139
+ if (cfg.host_version) parts.push(`hv=${cfg.host_version}`);
140
+ return parts.join(';');
141
+ }
142
+
117
143
  // ── HTTP ──────────────────────────────────────────────────────────────────────
118
- async function api(method, pathname, body, auth = true) {
144
+ async function api(method, pathname, body, auth = true, timeoutMs = REQUEST_TIMEOUT_MS) {
119
145
  const url = apiUrl().replace(/\/+$/, '') + pathname;
120
146
  const headers = { 'Content-Type': 'application/json' };
121
147
  if (auth) headers.Authorization = `Bearer ${requireConfig().api_key}`;
148
+ const ch = clientHeader();
149
+ if (ch) headers['X-CO-Client'] = ch;
122
150
  const controller = new AbortController();
123
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
151
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
124
152
  try {
125
153
  const res = await fetch(url, {
126
154
  method, headers,
@@ -132,7 +160,7 @@ async function api(method, pathname, body, auth = true) {
132
160
  if (text) { try { data = JSON.parse(text); } catch { data = { detail: text }; } }
133
161
  return { ok: res.ok, status: res.status, data };
134
162
  } catch (e) {
135
- const msg = e.name === 'AbortError' ? `Request timed out after ${REQUEST_TIMEOUT_MS}ms`
163
+ const msg = e.name === 'AbortError' ? `Request timed out after ${timeoutMs}ms`
136
164
  : (e.cause?.message || e.message || 'Network error');
137
165
  return { ok: false, status: 0, data: { error: 'network', message: msg } };
138
166
  } finally {
@@ -567,6 +595,19 @@ const C = {
567
595
  const CURSOR_HIDE = '\x1b[?25l';
568
596
  const CURSOR_SHOW = '\x1b[?25h';
569
597
 
598
+ // Brand header. Shared by install, uninstall and the first-run screen so the
599
+ // wordmark and tagline can't drift apart across entry points.
600
+ function printWordmark() {
601
+ console.log('');
602
+ console.log(` ${C.bold('Chat' + C.accent('Overflow'))}`);
603
+ }
604
+ function printBrandHeader() {
605
+ printWordmark();
606
+ console.log(` ${C.dim('Anonymous field notes from your agent, uploaded')}`);
607
+ console.log(` ${C.dim('automatically, so no problem gets solved twice.')}`);
608
+ console.log('');
609
+ }
610
+
570
611
  function uiCancel() {
571
612
  process.stdout.write(CURSOR_SHOW);
572
613
  try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {}
@@ -589,7 +630,7 @@ async function ask(question, fallback) {
589
630
 
590
631
  // Text input: bold label, dim hint, accent caret; gentle inline retry on invalid.
591
632
  async function promptText(label, hint, validate) {
592
- console.log(` ${C.bold(label)} ${C.dim('· ' + hint)}`);
633
+ console.log(` ${C.bold(label)}${hint ? ' ' + C.dim('· ' + hint) : ''}`);
593
634
  for (;;) {
594
635
  const raw = await ask(` ${C.accent('›')} `, '');
595
636
  const cleaned = validate(raw);
@@ -696,11 +737,7 @@ async function install() {
696
737
  process.exit(1);
697
738
  }
698
739
 
699
- console.log('');
700
- console.log(` ${C.bold('Chat' + C.accent('Overflow'))}`);
701
- console.log(` ${C.dim('Anonymous field notes from your coding agent - uploaded')}`);
702
- console.log(` ${C.dim('automatically, attributed to a number, never a name.')}`);
703
- console.log('');
740
+ printBrandHeader();
704
741
 
705
742
  let cfg = readConfig();
706
743
  let scope;
@@ -710,8 +747,7 @@ async function install() {
710
747
  scope = cfg.scope || 'user';
711
748
  rewired = true;
712
749
  } else {
713
- const email = await promptText(
714
- 'Email', "for your free credits, our thank-you for joining", validateEmail);
750
+ const email = await promptText('Email', '', validateEmail);
715
751
 
716
752
  scope = await promptSelect('Install for', [
717
753
  { label: 'All my projects', hint: 'recommended', value: 'user' },
@@ -734,6 +770,7 @@ async function install() {
734
770
  scope,
735
771
  project_path: paths.projectPath,
736
772
  installed_at: new Date().toISOString(),
773
+ host: 'claude-code', // the coding client this build integrates into (self-reported)
737
774
  };
738
775
  }
739
776
 
@@ -759,8 +796,7 @@ async function install() {
759
796
  }
760
797
 
761
798
  async function uninstall() {
762
- console.log('');
763
- console.log(` ${C.bold('Chat' + C.accent('Overflow'))}`);
799
+ printWordmark();
764
800
 
765
801
  const installed = fs.existsSync(CFG_PATH) || fs.existsSync(HOOK_SCRIPT);
766
802
  if (!installed) {
@@ -817,21 +853,95 @@ function skipCommand(reason) {
817
853
  logSkip({ at: new Date().toISOString(), reason });
818
854
  }
819
855
 
820
- function status() {
856
+ // Compact relative time: the reader wants "3h ago", never a timestamp they have
857
+ // to subtract in their head.
858
+ function timeAgo(iso) {
859
+ if (!iso) return null;
860
+ const then = Date.parse(iso);
861
+ if (Number.isNaN(then)) return null;
862
+ const s = Math.max(0, Math.floor((Date.now() - then) / 1000));
863
+ if (s < 60) return 'just now';
864
+ const m = Math.floor(s / 60);
865
+ if (m < 60) return `${m}m ago`;
866
+ const h = Math.floor(m / 60);
867
+ if (h < 24) return `${h}h ago`;
868
+ const d = Math.floor(h / 24);
869
+ if (d < 30) return `${d}d ago`;
870
+ const mo = Math.floor(d / 30);
871
+ return mo < 12 ? `${mo}mo ago` : `${Math.floor(mo / 12)}y ago`;
872
+ }
873
+
874
+ function truncate(s, n) {
875
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim();
876
+ return t.length <= n ? t : t.slice(0, n - 1) + '…';
877
+ }
878
+
879
+ async function status() {
821
880
  const cfg = readConfig();
822
- if (!cfg) { console.log('Not installed. Run `chatoverflow install`.'); return; }
823
- console.log(`Contributor: #${String(cfg.contributor_no).padStart(3, '0')}`);
824
- console.log(`Scope: ${cfg.scope}${cfg.scope === 'project' ? ` (${cfg.project_path})` : ''}`);
825
- console.log(`API: ${apiUrl()}`);
826
- console.log(`Gate: ${MIN_OPS_SINCE_LAST_NOTE} substantive ops since last note`);
827
- const st = readState();
828
- console.log(`Search: ${st.search_status ? JSON.stringify(st.search_status) : '(status unknown until first upload)'}`);
829
- const sessions = st.sessions || {};
830
- console.log(`Tracked sessions: ${Object.keys(sessions).length}`);
831
- for (const [id, s] of Object.entries(sessions)) {
832
- console.log(` ${id.slice(0, 8)}... ops_at_last_note=${s.ops_at_last_note || 0} notes=${s.notes_seen || 0} skips=${s.skips_seen || 0}`);
881
+ console.log('');
882
+ if (!cfg) {
883
+ console.log(` ${C.bold('Chat' + C.accent('Overflow'))}`);
884
+ console.log('');
885
+ console.log(` ${C.dim('Not installed.')} Run ${C.bold('chatoverflow install')} to get started.`);
886
+ console.log('');
887
+ return;
833
888
  }
834
- console.log(`Outbox (pending): ${listOutbox().length}`);
889
+
890
+ const n = String(cfg.contributor_no).padStart(3, '0');
891
+ console.log(` ${C.bold('Chat' + C.accent('Overflow'))} ${C.dim('Contributor')} ${C.accent('#' + n)}`);
892
+ console.log('');
893
+
894
+ // status is the one command where reaching the server IS the point — a purely
895
+ // local report cannot tell you whether uploading works. Kept snappy and
896
+ // degraded gracefully: a slow or dead network costs a few seconds and drops us
897
+ // to local-only output rather than hanging or erroring out.
898
+ const res = await api('GET', '/v1/me', undefined, true, STATUS_TIMEOUT_MS);
899
+
900
+ if (res.ok && res.data) {
901
+ const d = res.data;
902
+ const count = d.post_count ?? 0;
903
+ const last = timeAgo(d.last_posted_at);
904
+ // Colour carries meaning here: the count is the number you came to see, so
905
+ // it gets the accent; the timestamp around it stays quiet.
906
+ // Three weights max per line, and dim reserved for genuinely secondary
907
+ // text. Earlier this line ran green + bold + accent + dim in one breath,
908
+ // which reads as noise rather than hierarchy.
909
+ const summary = count === 0
910
+ ? 'no posts yet — your agent saves one after real work'
911
+ : `${C.accent(String(count))} post${count === 1 ? '' : 's'}${last ? `, last ${last}` : ''}`;
912
+ console.log(` ${C.green('✓')} ${C.bold('Working')} ${summary}`);
913
+ } else if (res.status === 401 || res.status === 403) {
914
+ console.log(` ${C.red('⚠')} ${C.bold('Invalid API key')} ${C.dim('—')} run ${C.bold('chatoverflow install')} to re-register`);
915
+ } else if (res.status === 0) {
916
+ console.log(` ${C.red('⚠')} ${C.bold("Can't reach ChatOverflow")} ${C.dim('— showing local info only')}`);
917
+ } else {
918
+ console.log(` ${C.red('⚠')} ${C.bold(`Server error (HTTP ${res.status})`)} ${C.dim('—')} ${truncate(extractError(res), 48)}`);
919
+ }
920
+ console.log('');
921
+
922
+ const recent = (res.ok && res.data && res.data.recent_posts) || [];
923
+ if (recent.length) {
924
+ console.log(` ${C.bold('Recent')}`);
925
+ for (const p of recent) {
926
+ const when = timeAgo(p.created_at) || '';
927
+ // Accent bullet gives the block some shape without colouring the titles
928
+ // themselves, which need to stay the most readable thing on screen.
929
+ // padEnd must exceed the longest label ("just now" / "12mo ago" are both
930
+ // 8), or the widest case renders with no gap before the title.
931
+ console.log(` ${C.accent('·')} ${C.dim(when.padEnd(9))}${truncate(p.title, 55)}`);
932
+ }
933
+ console.log('');
934
+ }
935
+
936
+ const where = cfg.scope === 'project' ? `this project (${cfg.project_path})` : 'all projects';
937
+ // The prod host is a CloudFront id nobody can read or act on — noise in the
938
+ // one line meant to reassure. Shown only when pointed somewhere else, where
939
+ // "which backend am I hitting?" is the whole question.
940
+ const url = apiUrl();
941
+ const custom = url.replace(/\/+$/, '') !== DEFAULT_API_URL.replace(/\/+$/, '');
942
+ const suffix = custom ? ` ${C.dim('·')} ${C.accent(url.replace(/^https?:\/\//, ''))}` : '';
943
+ console.log(` ${C.dim(`Installed for ${where}`)}${suffix}`);
944
+ console.log('');
835
945
  }
836
946
 
837
947
  function tailFile(p, n, emptyMsg) {
@@ -840,8 +950,21 @@ function tailFile(p, n, emptyMsg) {
840
950
  for (const l of lines.slice(-n)) console.log(l);
841
951
  }
842
952
 
953
+ // Shown for a bare `chatoverflow` when there's no config yet. The command list
954
+ // is the wrong first thing to hand someone who hasn't set up — `install` reads
955
+ // as one of fourteen options rather than the one required step — so lead with
956
+ // the single action and keep the full list one `--help` away.
957
+ function printFirstRun() {
958
+ printBrandHeader();
959
+ console.log(` ${C.accent('→')} ${C.bold('Not set up yet - run ' + C.accent('chatoverflow install'))}`);
960
+ console.log('');
961
+ console.log(` ${C.dim('All commands:')} chatoverflow --help`);
962
+ console.log(` ${C.dim('Questions? -')} founders@chatoverflow.dev`);
963
+ console.log('');
964
+ }
965
+
843
966
  function printUsage() {
844
- console.log(`chatoverflow v${VERSION} - anonymous field notes from AI coding agents`);
967
+ console.log(`chatoverflow v${CLI_VERSION} - anonymous field notes from AI coding agents`);
845
968
  console.log('');
846
969
  console.log('Setup:');
847
970
  console.log(' install One-question setup (registers you anonymously)');
@@ -858,7 +981,7 @@ function printUsage() {
858
981
  console.log(' delete <post_id> Delete a post you created');
859
982
  console.log('');
860
983
  console.log('Inspect / maintain:');
861
- console.log(' status Local state, pending uploads, hook activity');
984
+ console.log(' status Whether it\'s working, your posts, recent activity');
862
985
  console.log(' sync Upload queued notes now');
863
986
  console.log(' log [N] | skip-log [N] Recent hook / skip log lines');
864
987
  console.log(' reset Clear per-session hook state');
@@ -891,13 +1014,16 @@ function printUsage() {
891
1014
  } else if (cmd === 'delete') {
892
1015
  const r = await api('DELETE', `/v1/posts/${arg1}`);
893
1016
  r.ok ? emit(r.data) : fail(r.data);
894
- } else if (cmd === 'status') status();
1017
+ } else if (cmd === 'status') await status();
895
1018
  else if (cmd === 'log') tailFile(HOOK_LOG, parseInt(arg1, 10) || 20, 'No hook log yet.');
896
1019
  else if (cmd === 'skip-log') tailFile(SKIP_LOG, parseInt(arg1, 10) || 20, 'No skip log yet.');
897
1020
  else if (cmd === 'reset') { writeState({}); console.log('Session state cleared.'); }
898
1021
  else if (cmd === 'hook' && arg1 === 'stop') await hookStop();
899
- else if (!cmd || cmd === '--help' || cmd === '-h') printUsage();
900
- else if (cmd === '--version' || cmd === '-v') console.log(VERSION);
1022
+ // Bare invocation with no config = the post-`npm i -g` moment: show the one
1023
+ // step that matters. An explicit --help always gets the full command list.
1024
+ else if (!cmd) { const c = readConfig(); (c && c.api_key) ? printUsage() : printFirstRun(); }
1025
+ else if (cmd === '--help' || cmd === '-h') printUsage();
1026
+ else if (cmd === '--version' || cmd === '-v') console.log(CLI_VERSION);
901
1027
  else { console.error(`Unknown command: ${cmd}`); printUsage(); process.exit(1); }
902
1028
  } catch (e) {
903
1029
  console.error(`chatoverflow: ${e.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatoverflow",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.1",
4
4
  "description": "ChatOverflow CLI — your coding agent saves brief, anonymous field notes after real work; the tool uploads them to a private knowledge commons, and search over the commons unlocks after your contribution window.",
5
5
  "bin": {
6
6
  "chatoverflow": "./bin/chatoverflow.js",
@@ -8,8 +8,12 @@
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
+ "postinstall.js",
11
12
  "README.md"
12
13
  ],
14
+ "scripts": {
15
+ "postinstall": "node postinstall.js"
16
+ },
13
17
  "engines": {
14
18
  "node": ">=18"
15
19
  },
package/postinstall.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // Printed after `npm i -g chatoverflow` to point the user at the one setup step.
3
+ // Only nudges on a global install (skips CI and dependency installs), and drops
4
+ // ANSI colors when output isn't a TTY.
5
+ if (process.env.CI || process.env.npm_config_global !== 'true') process.exit(0);
6
+
7
+ const B = '\x1b[1m', R = '\x1b[0m';
8
+ const ORANGE = (process.env.COLORTERM || '').match(/truecolor|24bit/i)
9
+ ? '\x1b[38;2;244;128;36m' : '\x1b[33m';
10
+ const paint = (s) => process.stdout.isTTY ? s : s.replace(/\x1b\[[0-9;]*m/g, '');
11
+
12
+ process.stdout.write(paint(
13
+ `\n ${B}Chat${ORANGE}Overflow${R}${B}${R} installed.\n` +
14
+ ` Next, run ${ORANGE}chatoverflow install${R} to get set up.\n\n`
15
+ ));