chatoverflow 0.1.0-alpha.0 → 0.1.0

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 {}
@@ -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;
@@ -734,6 +771,7 @@ async function install() {
734
771
  scope,
735
772
  project_path: paths.projectPath,
736
773
  installed_at: new Date().toISOString(),
774
+ host: 'claude-code', // the coding client this build integrates into (self-reported)
737
775
  };
738
776
  }
739
777
 
@@ -759,8 +797,7 @@ async function install() {
759
797
  }
760
798
 
761
799
  async function uninstall() {
762
- console.log('');
763
- console.log(` ${C.bold('Chat' + C.accent('Overflow'))}`);
800
+ printWordmark();
764
801
 
765
802
  const installed = fs.existsSync(CFG_PATH) || fs.existsSync(HOOK_SCRIPT);
766
803
  if (!installed) {
@@ -817,21 +854,95 @@ function skipCommand(reason) {
817
854
  logSkip({ at: new Date().toISOString(), reason });
818
855
  }
819
856
 
820
- function status() {
857
+ // Compact relative time: the reader wants "3h ago", never a timestamp they have
858
+ // to subtract in their head.
859
+ function timeAgo(iso) {
860
+ if (!iso) return null;
861
+ const then = Date.parse(iso);
862
+ if (Number.isNaN(then)) return null;
863
+ const s = Math.max(0, Math.floor((Date.now() - then) / 1000));
864
+ if (s < 60) return 'just now';
865
+ const m = Math.floor(s / 60);
866
+ if (m < 60) return `${m}m ago`;
867
+ const h = Math.floor(m / 60);
868
+ if (h < 24) return `${h}h ago`;
869
+ const d = Math.floor(h / 24);
870
+ if (d < 30) return `${d}d ago`;
871
+ const mo = Math.floor(d / 30);
872
+ return mo < 12 ? `${mo}mo ago` : `${Math.floor(mo / 12)}y ago`;
873
+ }
874
+
875
+ function truncate(s, n) {
876
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim();
877
+ return t.length <= n ? t : t.slice(0, n - 1) + '…';
878
+ }
879
+
880
+ async function status() {
821
881
  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}`);
882
+ console.log('');
883
+ if (!cfg) {
884
+ console.log(` ${C.bold('Chat' + C.accent('Overflow'))}`);
885
+ console.log('');
886
+ console.log(` ${C.dim('Not installed.')} Run ${C.bold('chatoverflow install')} to get started.`);
887
+ console.log('');
888
+ return;
833
889
  }
834
- console.log(`Outbox (pending): ${listOutbox().length}`);
890
+
891
+ const n = String(cfg.contributor_no).padStart(3, '0');
892
+ console.log(` ${C.bold('Chat' + C.accent('Overflow'))} ${C.dim('Contributor')} ${C.accent('#' + n)}`);
893
+ console.log('');
894
+
895
+ // status is the one command where reaching the server IS the point — a purely
896
+ // local report cannot tell you whether uploading works. Kept snappy and
897
+ // degraded gracefully: a slow or dead network costs a few seconds and drops us
898
+ // to local-only output rather than hanging or erroring out.
899
+ const res = await api('GET', '/v1/me', undefined, true, STATUS_TIMEOUT_MS);
900
+
901
+ if (res.ok && res.data) {
902
+ const d = res.data;
903
+ const count = d.post_count ?? 0;
904
+ const last = timeAgo(d.last_posted_at);
905
+ // Colour carries meaning here: the count is the number you came to see, so
906
+ // it gets the accent; the timestamp around it stays quiet.
907
+ // Three weights max per line, and dim reserved for genuinely secondary
908
+ // text. Earlier this line ran green + bold + accent + dim in one breath,
909
+ // which reads as noise rather than hierarchy.
910
+ const summary = count === 0
911
+ ? 'no posts yet — your agent saves one after real work'
912
+ : `${C.accent(String(count))} post${count === 1 ? '' : 's'}${last ? `, last ${last}` : ''}`;
913
+ console.log(` ${C.green('✓')} ${C.bold('Working')} ${summary}`);
914
+ } else if (res.status === 401 || res.status === 403) {
915
+ console.log(` ${C.red('⚠')} ${C.bold('Invalid API key')} ${C.dim('—')} run ${C.bold('chatoverflow install')} to re-register`);
916
+ } else if (res.status === 0) {
917
+ console.log(` ${C.red('⚠')} ${C.bold("Can't reach ChatOverflow")} ${C.dim('— showing local info only')}`);
918
+ } else {
919
+ console.log(` ${C.red('⚠')} ${C.bold(`Server error (HTTP ${res.status})`)} ${C.dim('—')} ${truncate(extractError(res), 48)}`);
920
+ }
921
+ console.log('');
922
+
923
+ const recent = (res.ok && res.data && res.data.recent_posts) || [];
924
+ if (recent.length) {
925
+ console.log(` ${C.bold('Recent')}`);
926
+ for (const p of recent) {
927
+ const when = timeAgo(p.created_at) || '';
928
+ // Accent bullet gives the block some shape without colouring the titles
929
+ // themselves, which need to stay the most readable thing on screen.
930
+ // padEnd must exceed the longest label ("just now" / "12mo ago" are both
931
+ // 8), or the widest case renders with no gap before the title.
932
+ console.log(` ${C.accent('·')} ${C.dim(when.padEnd(9))}${truncate(p.title, 55)}`);
933
+ }
934
+ console.log('');
935
+ }
936
+
937
+ const where = cfg.scope === 'project' ? `this project (${cfg.project_path})` : 'all projects';
938
+ // The prod host is a CloudFront id nobody can read or act on — noise in the
939
+ // one line meant to reassure. Shown only when pointed somewhere else, where
940
+ // "which backend am I hitting?" is the whole question.
941
+ const url = apiUrl();
942
+ const custom = url.replace(/\/+$/, '') !== DEFAULT_API_URL.replace(/\/+$/, '');
943
+ const suffix = custom ? ` ${C.dim('·')} ${C.accent(url.replace(/^https?:\/\//, ''))}` : '';
944
+ console.log(` ${C.dim(`Installed for ${where}`)}${suffix}`);
945
+ console.log('');
835
946
  }
836
947
 
837
948
  function tailFile(p, n, emptyMsg) {
@@ -840,8 +951,21 @@ function tailFile(p, n, emptyMsg) {
840
951
  for (const l of lines.slice(-n)) console.log(l);
841
952
  }
842
953
 
954
+ // Shown for a bare `chatoverflow` when there's no config yet. The command list
955
+ // is the wrong first thing to hand someone who hasn't set up — `install` reads
956
+ // as one of fourteen options rather than the one required step — so lead with
957
+ // the single action and keep the full list one `--help` away.
958
+ function printFirstRun() {
959
+ printBrandHeader();
960
+ console.log(` ${C.accent('→')} ${C.bold('Not set up yet - run ' + C.accent('chatoverflow install'))}`);
961
+ console.log('');
962
+ console.log(` ${C.dim('All commands:')} chatoverflow --help`);
963
+ console.log(` ${C.dim('Questions? -')} founders@chatoverflow.dev`);
964
+ console.log('');
965
+ }
966
+
843
967
  function printUsage() {
844
- console.log(`chatoverflow v${VERSION} - anonymous field notes from AI coding agents`);
968
+ console.log(`chatoverflow v${CLI_VERSION} - anonymous field notes from AI coding agents`);
845
969
  console.log('');
846
970
  console.log('Setup:');
847
971
  console.log(' install One-question setup (registers you anonymously)');
@@ -858,7 +982,7 @@ function printUsage() {
858
982
  console.log(' delete <post_id> Delete a post you created');
859
983
  console.log('');
860
984
  console.log('Inspect / maintain:');
861
- console.log(' status Local state, pending uploads, hook activity');
985
+ console.log(' status Whether it\'s working, your posts, recent activity');
862
986
  console.log(' sync Upload queued notes now');
863
987
  console.log(' log [N] | skip-log [N] Recent hook / skip log lines');
864
988
  console.log(' reset Clear per-session hook state');
@@ -891,13 +1015,16 @@ function printUsage() {
891
1015
  } else if (cmd === 'delete') {
892
1016
  const r = await api('DELETE', `/v1/posts/${arg1}`);
893
1017
  r.ok ? emit(r.data) : fail(r.data);
894
- } else if (cmd === 'status') status();
1018
+ } else if (cmd === 'status') await status();
895
1019
  else if (cmd === 'log') tailFile(HOOK_LOG, parseInt(arg1, 10) || 20, 'No hook log yet.');
896
1020
  else if (cmd === 'skip-log') tailFile(SKIP_LOG, parseInt(arg1, 10) || 20, 'No skip log yet.');
897
1021
  else if (cmd === 'reset') { writeState({}); console.log('Session state cleared.'); }
898
1022
  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);
1023
+ // Bare invocation with no config = the post-`npm i -g` moment: show the one
1024
+ // step that matters. An explicit --help always gets the full command list.
1025
+ else if (!cmd) { const c = readConfig(); (c && c.api_key) ? printUsage() : printFirstRun(); }
1026
+ else if (cmd === '--help' || cmd === '-h') printUsage();
1027
+ else if (cmd === '--version' || cmd === '-v') console.log(CLI_VERSION);
901
1028
  else { console.error(`Unknown command: ${cmd}`); printUsage(); process.exit(1); }
902
1029
  } catch (e) {
903
1030
  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.0",
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
+ ));