syndes 0.1.0 → 0.3.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/bin/cli.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from 'node:fs';
11
11
  import { spawnSync } from 'node:child_process';
12
12
  import { join } from 'node:path';
13
+ import { homedir } from 'node:os';
13
14
  import {
14
15
  packageRoot, displayPath, dataDir, installDir, workerScript, pauseFile,
15
16
  ensureDataDirs, installedCliScript, isInstalledCopy,
@@ -41,6 +42,11 @@ import { openUrl } from '../src/open.mjs';
41
42
  import { pendingCount } from '../runtime/spool.mjs';
42
43
  import { detectAll, adapterFor, ADAPTERS } from '../adapters/index.mjs';
43
44
  import { pollTails, resetCursors } from '../collect/tail.mjs';
45
+ import { joinPool, leave as leavePool, syncOnce, readPool, isEnabled, teamConfig, loadState, poolRoot, inviteCode, removeDevice, poolName } from '../sync/index.mjs';
46
+ import { decode as decodeInvite, commandFor } from '../sync/invite.mjs';
47
+ import { diagnose as preflight, repair as repairAuth, ghInstallHint } from '../src/preflight.mjs';
48
+ import { identity, setName, initialsOf } from '../runtime/identity.mjs';
49
+ import { team as teamView } from '../analytics/team.mjs';
44
50
 
45
51
  const VERSION = readVersion();
46
52
 
@@ -48,7 +54,7 @@ const COMMANDS = {
48
54
  install: cmdInstall, uninstall: cmdUninstall, doctor: cmdDoctor,
49
55
  status: cmdStatus, report: cmdReport, dashboard: cmdDashboard, ui: cmdDashboard,
50
56
  lock: cmdLock, verify: cmdVerify, sessions: cmdSessions,
51
- sources: cmdSources, watch: cmdWatch,
57
+ sources: cmdSources, watch: cmdWatch, team: cmdTeam, join: cmdJoin,
52
58
  projects: cmdProjects, practices: cmdPractices, habits: cmdPractices, coach: cmdCoach,
53
59
  ledger: cmdLedger, export: cmdExport, rebuild: cmdRebuild, drain: cmdDrain,
54
60
  archive: cmdArchive, prune: cmdPrune, config: cmdConfig,
@@ -119,10 +125,114 @@ async function cmdInstall(args) {
119
125
  if (found.length) scan.succeed(found.map((agent) => agent.name).join(', '));
120
126
  else scan.skip('none besides Claude Code');
121
127
 
128
+ await setUpSharing(args);
129
+
122
130
  await runBriefing(result);
123
131
  clearPending();
124
132
  }
125
133
 
134
+ /**
135
+ * The sharing question, asked during install rather than left to be discovered.
136
+ *
137
+ * Several people on one Claude account is the case SynDes cannot see without
138
+ * being told, and somebody who does not know the feature exists will never run
139
+ * `syndes team join`. So install asks — once, in the one moment the user is
140
+ * already configuring the tool.
141
+ *
142
+ * Three things this must never do:
143
+ * • Ask when nobody is there to answer. The npm postinstall runs install()
144
+ * with no TTY, and a prompt there would hang `npm install -g` forever.
145
+ * • Ask again on a re-install. Repairing an install is not an invitation to
146
+ * re-interview somebody about a decision they already made.
147
+ * • Fail the install. A pool that cannot be reached is a pool problem; the
148
+ * hooks are wired and the ledger is open either way.
149
+ */
150
+ async function setUpSharing(args = []) {
151
+ const flag = (name) => args.find((arg) => arg.startsWith(`--${name}=`))?.split('=').slice(1).join('=');
152
+ const repoFlag = flag('team');
153
+ const nameFlag = flag('name');
154
+
155
+ if (args.includes('--no-team')) return;
156
+
157
+ // Already in a pool: report, do not re-ask.
158
+ if (isEnabled() && !repoFlag) {
159
+ const step = spinner('Checking the shared pool');
160
+ await beat();
161
+ const state = syncOnce();
162
+ if (state.ok) step.succeed(`${identity().name} · ${readPool().length} machine(s)`);
163
+ else step.fail(state.error ?? 'could not reach the pool');
164
+ return;
165
+ }
166
+
167
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI;
168
+ if (!repoFlag && !interactive) return; // nobody is there to answer
169
+
170
+ let repo = repoFlag;
171
+ let who = nameFlag;
172
+
173
+ if (!repo) {
174
+ write();
175
+ write(` ${bold('Is anyone else using this Claude account?')}`);
176
+ write(grey(' Two people on one account look like one user. SynDes can pool each'));
177
+ write(grey(' machine\'s numbers through a private repo so you see the real split.'));
178
+ write(grey(' Counts and tokens are shared. Prompts, paths and commands never are.'));
179
+ write();
180
+
181
+ if (!(await confirm(' Set up sharing now?'))) {
182
+ write(grey(` ${DOT} skipped — ${cyan('syndes team join <repo>')} whenever you want it`));
183
+ write();
184
+ return;
185
+ }
186
+
187
+ write();
188
+ write(grey(' A private git repo everyone can push to, or a synced folder.'));
189
+ write(grey(' e.g. git@github.com:you/usage.git or ~/Dropbox/usage'));
190
+ repo = (await ask(' Repository or folder: ')).trim();
191
+ if (!repo) {
192
+ write(grey(` ${DOT} nothing entered — ${cyan('syndes team join <repo>')} later`));
193
+ write();
194
+ return;
195
+ }
196
+ who = who || (await ask(` Your name [${identity().name}]: `)).trim();
197
+ }
198
+
199
+ if (who) {
200
+ try { setName(who); } catch { /* an unusable name is not worth failing an install over */ }
201
+ }
202
+
203
+ const path = repo.startsWith('~') ? repo.replace('~', homedir()) : repo;
204
+ const transport = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/)/.test(path) || path.endsWith('.git')
205
+ ? 'git'
206
+ : 'folder';
207
+
208
+ write();
209
+ const step = spinner(`Joining the pool over ${transport}`);
210
+ await beat();
211
+ try {
212
+ joinPool({ transport, repo: path });
213
+ } catch (error) {
214
+ // Deliberately not fatal. The install succeeded; only the pool did not.
215
+ step.fail(error.message);
216
+ write(grey(` ${DOT} everything else is installed — fix the repo and run ${cyan('syndes team join')}`));
217
+ write();
218
+ return;
219
+ }
220
+ step.succeed(path);
221
+
222
+ const share = spinner('Publishing this machine');
223
+ await beat();
224
+ const state = syncOnce();
225
+ if (state.ok) {
226
+ share.succeed(`${state.published} day(s) shared as ${identity().name}`);
227
+ const others = readPool().filter((peer) => !peer.isMe);
228
+ if (others.length) write(grey(` ${DOT} already in the pool: ${others.map((peer) => peer.name).join(', ')}`));
229
+ } else {
230
+ share.fail(state.error ?? 'could not publish');
231
+ write(grey(` ${DOT} joined, but the exchange failed — check git access, then ${cyan('syndes team sync')}`));
232
+ }
233
+ write();
234
+ }
235
+
126
236
  async function cmdUninstall(args) {
127
237
  const purge = args.includes('--purge');
128
238
 
@@ -484,6 +594,305 @@ async function cmdWatch(args) {
484
594
  process.on('SIGINT', () => { clearInterval(timer); write(); process.exit(0); });
485
595
  }
486
596
 
597
+ /**
598
+ * `syndes join <invite>` — everything a newcomer has to do, in one command.
599
+ *
600
+ * The person receiving an invite has not read any of this and should not have
601
+ * to. So this installs if it is not installed, works out why git cannot reach
602
+ * the repository, offers to fix that, joins, and publishes — reporting each
603
+ * step rather than failing with one line about credentials.
604
+ */
605
+ async function cmdJoin(args) {
606
+ const raw = args.find((arg) => !arg.startsWith('--'));
607
+ if (!raw) throw new Error('usage: syndes join <invite-code> (the line whoever invited you sent)');
608
+
609
+ const invite = decodeInvite(raw);
610
+ if (!invite) {
611
+ throw new Error('that does not look like an invite — paste the whole line you were sent, or use: syndes team join <repo-url>');
612
+ }
613
+
614
+ banner();
615
+ write(` Joining ${bold(invite.pool ?? 'the pool')}${invite.from ? grey(`, invited by ${invite.from}`) : ''}`);
616
+ write(grey(` ${invite.repo}`));
617
+ write();
618
+
619
+ if (!isInstalledHere()) {
620
+ const step = spinner('Setting up SynDes on this machine');
621
+ await beat();
622
+ await install({});
623
+ step.succeed(displayPath(installDir));
624
+ } else {
625
+ write(` ${grey(DOT)} ${grey('already installed')}`);
626
+ }
627
+
628
+ if (!(await ensureReachable(invite.repo, args))) return;
629
+
630
+ let who = args.find((arg) => arg.startsWith('--name='))?.split('=').slice(1).join('=');
631
+ if (!who && process.stdin.isTTY) {
632
+ write();
633
+ who = (await ask(` Your name, as the others will see it [${identity().name}]: `)).trim();
634
+ }
635
+ if (who) setName(who);
636
+
637
+ write();
638
+ const step = spinner('Joining the pool');
639
+ await beat();
640
+ try {
641
+ joinPool({ transport: invite.transport, repo: invite.repo });
642
+ } catch (error) {
643
+ step.fail(error.message);
644
+ process.exitCode = 1;
645
+ return;
646
+ }
647
+ step.succeed(poolName());
648
+
649
+ const share = spinner('Publishing this machine');
650
+ await beat();
651
+ const state = syncOnce();
652
+ if (state.ok) share.succeed(`${state.published} day(s) shared as ${identity().name}`);
653
+ else share.fail(state.error ?? 'could not publish');
654
+
655
+ const others = readPool().filter((peer) => !peer.isMe);
656
+ write();
657
+ write(` ${OK} you are in${others.length ? grey(` · also here: ${others.map((peer) => peer.name).join(', ')}`) : ''}`);
658
+ write(grey(` ${cyan('syndes dashboard')} then open Team`));
659
+ write();
660
+ }
661
+
662
+ function isInstalledHere() {
663
+ try {
664
+ return (adapterFor('claude-code').status?.().wired ?? 0) > 0;
665
+ } catch {
666
+ return false;
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Check git can reach the repository, and fix it if we can.
672
+ *
673
+ * The confusing case this exists for: accepting a collaborator invite in a
674
+ * browser authorises the ACCOUNT, while git on this machine still cannot prove
675
+ * who it is — and GitHub answers that with "not found" rather than "not you".
676
+ */
677
+ async function ensureReachable(repo, args = []) {
678
+ const step = spinner('Checking access to the repository');
679
+ await beat();
680
+ let report = preflight(repo);
681
+
682
+ if (report.ok) {
683
+ // Only claim an account when a reach check actually ran and passed with it.
684
+ // A folder pool, or a local path, never touches GitHub — saying "as <user>"
685
+ // there would credit an authentication that was not tested.
686
+ const tested = report.checks.some((check) => check.name === 'repository' && check.ok);
687
+ step.succeed(tested && report.gh.account ? `as ${report.gh.account}` : tested ? 'reachable' : 'no sign-in needed');
688
+ return true;
689
+ }
690
+ step.fail(report.blocking.detail);
691
+
692
+ if (report.gh.installed && process.stdin.isTTY && !args.includes('--no-fix')) {
693
+ write();
694
+ write(grey(' This is usually a sign-in, not a missing repository.'));
695
+ if (await confirm(' Sign in to GitHub now?', true)) {
696
+ write();
697
+ const fixed = repairAuth({ interactive: true });
698
+ write();
699
+ if (fixed.ran.length) write(grey(` ran: ${fixed.ran.join(', ')}`));
700
+ const retry = spinner('Checking access again');
701
+ await beat();
702
+ report = preflight(repo);
703
+ if (report.ok) {
704
+ retry.succeed(report.gh.account ? `as ${report.gh.account}` : 'reachable');
705
+ return true;
706
+ }
707
+
708
+ retry.fail(report.blocking.detail);
709
+ }
710
+ }
711
+
712
+ write();
713
+ write(` ${cyan('→')} ${report.blocking.fix}`);
714
+ if (!report.gh.installed) write(` ${cyan('→')} the GitHub CLI makes this one command: ${ghInstallHint()}`);
715
+ write(grey(' then run this again — nothing is lost, recording has already started'));
716
+ write();
717
+ process.exitCode = 1;
718
+ return false;
719
+ }
720
+
721
+ /**
722
+ * `syndes team …` — the shared-account pool.
723
+ *
724
+ * Sub-verbs rather than top-level commands: everything here is meaningless
725
+ * unless sharing is on, and hanging six more verbs off the root would bury the
726
+ * ones that matter to somebody using this alone.
727
+ */
728
+ async function cmdTeam(args) {
729
+ const [verb = 'status', ...rest] = args;
730
+
731
+ if (verb === 'join') {
732
+ const repo = rest.find((arg) => !arg.startsWith('--'));
733
+ if (!repo) throw new Error('usage: syndes team join <repo-url|folder> [--folder] [--detailed]');
734
+
735
+ const transport = rest.includes('--folder') || !/^(https?:\/\/|git@|ssh:\/\/|git:\/\/)/.test(repo)
736
+ ? (repo.endsWith('.git') ? 'git' : 'folder')
737
+ : 'git';
738
+
739
+ const spin = spinner(`Joining the pool over ${transport}`);
740
+ await beat();
741
+ let result;
742
+ try {
743
+ result = joinPool({ transport, repo, scope: rest.includes('--detailed') ? 'detailed' : undefined });
744
+ } catch (error) {
745
+ spin.fail(error.message);
746
+ throw error;
747
+ }
748
+ spin.succeed(result.describe ?? repo);
749
+
750
+ const push = spinner('Publishing this machine');
751
+ await beat();
752
+ const state = syncOnce();
753
+ if (state.ok) push.succeed(`${state.published} day(s) shared`);
754
+ else push.fail(state.error ?? 'could not reach the pool');
755
+
756
+ write();
757
+ write(grey(` You appear as ${bold(identity().name)}. ${cyan('syndes team name <you>')} to change it.`));
758
+ write(grey(` ${cyan('syndes dashboard')} then open Team for the live view.`));
759
+ write();
760
+ return;
761
+ }
762
+
763
+ if (verb === 'leave') {
764
+ leavePool();
765
+ write(`${OK} left the pool ${grey('· your shelf stays until someone deletes it')}`);
766
+ return;
767
+ }
768
+
769
+ if (verb === 'name') {
770
+ if (!rest.length) throw new Error('usage: syndes team name <your name>');
771
+ const me = setName(rest.join(' '));
772
+ if (isEnabled()) syncOnce();
773
+ write(`${OK} this machine is ${bold(me.name)}`);
774
+ return;
775
+ }
776
+
777
+ if (verb === 'scope') {
778
+ const scope = rest[0];
779
+ if (!['summary', 'detailed'].includes(scope)) throw new Error('usage: syndes team scope <summary|detailed>');
780
+ const next = rawConfig();
781
+ setPath(next, 'team.scope', scope);
782
+ saveConfig(next);
783
+ if (isEnabled()) syncOnce();
784
+ write(`${OK} sharing ${bold(scope)} ${grey('· prompt text is never shared, at any scope')}`);
785
+ return;
786
+ }
787
+
788
+ if (verb === 'invite') {
789
+ if (!isEnabled()) throw new Error('sharing is off — syndes team join <repo> first');
790
+ const code = inviteCode();
791
+ write();
792
+ write(` ${bold('Send this line')} ${grey('· they paste it into a terminal, and that is all')}`);
793
+ write();
794
+ write(` ${cyan(commandFor(code))}`);
795
+ write();
796
+ write(grey(' It installs SynDes, checks their GitHub access, offers to fix it,'));
797
+ write(grey(' and joins them to this pool.'));
798
+ write();
799
+ write(grey(` ${WARN} first add them as a collaborator on ${teamConfig().repo}`));
800
+ write(grey(' The invite is not a key — access is the repository\'s collaborator list.'));
801
+ write(grey(' Everyone in the pool can see everyone else in it.'));
802
+ write();
803
+ return;
804
+ }
805
+
806
+ if (verb === 'remove') {
807
+ const target = rest.filter((arg) => !arg.startsWith('--')).join(' ');
808
+ if (!target) throw new Error('usage: syndes team remove <name>');
809
+ if (!isEnabled()) throw new Error('sharing is off');
810
+
811
+ const peers = readPool();
812
+ const match = peers.find((p) => p.deviceId === target || p.name?.toLowerCase() === target.toLowerCase());
813
+ if (!match) throw new Error(`no machine called "${target}" — ${peers.map((p) => p.name).join(', ')}`);
814
+
815
+ write();
816
+ write(` ${WARN} removing ${bold(match.name)} takes their ${match.rollups.size} published day(s) out of the pool`);
817
+ write(grey(' Their own ledger is untouched. Past totals will change for everyone.'));
818
+ write(grey(' Git keeps the old commits, so this removes them from view, not from history.'));
819
+ write(grey(` To stop them adding more, remove their GitHub access as well.`));
820
+ write();
821
+ if (process.stdin.isTTY && !rest.includes('--yes') && !(await confirm(` Remove ${match.name}?`))) return write('cancelled');
822
+
823
+ const step = spinner(`Removing ${match.name}`);
824
+ await beat();
825
+ const result = removeDevice(match.deviceId);
826
+ if (!result.removed) { step.fail(result.error); process.exitCode = 1; return; }
827
+ if (result.pushed) step.succeed('removed and pushed');
828
+ else step.fail(`removed locally, but the push failed: ${result.error ?? 'unknown'}`);
829
+ return;
830
+ }
831
+
832
+ if (verb === 'doctor') {
833
+ const report = preflight(teamConfig().repo ?? null);
834
+ write();
835
+ for (const check of report.checks) {
836
+ const mark = check.ok === true ? OK : check.ok === false ? FAIL : grey(DOT);
837
+ write(` ${mark} ${check.name.padEnd(12)} ${check.ok === false ? check.detail : grey(check.detail)}`);
838
+ if (check.fix) write(` ${cyan('→')} ${check.fix}`);
839
+ }
840
+ write();
841
+ if (!report.ok) process.exitCode = 1;
842
+ return;
843
+ }
844
+
845
+ if (verb === 'sync') {
846
+ if (!isEnabled()) return write(grey('sharing is off — syndes team join <repo>'));
847
+ const spin = spinner('Exchanging with the pool');
848
+ await beat();
849
+ const state = syncOnce();
850
+ if (state.ok) spin.succeed(`${state.published} day(s) published${state.pushed ? ', pushed' : ', nothing new to push'}`);
851
+ else spin.fail(state.error ?? 'sync failed');
852
+ if (!state.ok) process.exitCode = 1;
853
+ return;
854
+ }
855
+
856
+ // status
857
+ if (!isEnabled()) {
858
+ write();
859
+ write(` ${grey('sharing is')} ${bold('off')}`);
860
+ write();
861
+ write(grey(' One Claude account used by several people shows up as one user.'));
862
+ write(grey(' A pool gives each machine its own shelf, so the split is visible.'));
863
+ write();
864
+ write(` ${cyan('syndes team join git@github.com:you/usage.git')}`);
865
+ write(` ${cyan('syndes team join ~/Dropbox/usage --folder')}`);
866
+ write();
867
+ return;
868
+ }
869
+
870
+ const data = await teamView(rest[0] ?? '7d');
871
+ const state = loadState();
872
+
873
+ write();
874
+ write(` ${bold('Pool')} ${grey(poolRoot())}`);
875
+ write(` ${grey('transport')} ${teamConfig().transport} ${grey('scope')} ${teamConfig().scope} ${grey('last sync')} ${state.at ? new Date(state.at).toLocaleTimeString() : '—'}${state.error ? red(' · ' + String(state.error).slice(0, 60)) : ''}`);
876
+ write();
877
+ write(table([
878
+ [grey(''), grey('person'), grey('share'), grey('tokens'), grey('sessions'), grey('active'), grey('last seen')],
879
+ ...data.peers.map((peer) => [
880
+ peer.isMe ? green('›') : ' ',
881
+ `${peer.name}${peer.stale ? yellow(' (stale)') : ''}`,
882
+ percent(peer.share),
883
+ compact(peer.totals.tokens),
884
+ String(peer.totals.sessions),
885
+ duration(peer.totals.activeMs),
886
+ peer.lastSeen ? new Date(peer.lastSeen).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—',
887
+ ]),
888
+ ], { align: ['left', 'left', 'right', 'right', 'right', 'right', 'left'] }).map((line) => ` ${line}`).join('\n'));
889
+ write();
890
+ write(` ${grey('overlap')} ${percent(data.grid.overlapRate)} ${grey(`of working hours had more than one of you in them`)}`);
891
+ write();
892
+ write(grey(` ${cyan('syndes team invite')} to add someone ${DOT} ${cyan('syndes team remove <name>')} ${DOT} ${cyan('syndes team doctor')}`));
893
+ write();
894
+ }
895
+
487
896
  /** `syndes lock [open|system|pin]` */
488
897
  async function cmdLock(args) {
489
898
  const requested = args[0];
@@ -577,6 +986,9 @@ function usage_() {
577
986
  [cyan('export --format=csv'), 'all of it, back out'],
578
987
  [cyan('sources'), 'which coding agents SynDes can see'],
579
988
  [cyan('watch [--once]'), 'follow agents that have no hook API'],
989
+ [cyan('join <invite>'), 'accept an invite — installs and sets everything up'],
990
+ [cyan('team invite'), 'a line to send someone so they can join'],
991
+ [cyan('team [status|remove]'), 'share one account and see the split'],
580
992
  [cyan('lock [mode]'), 'how the dashboard unlocks: open, system or pin'],
581
993
  [cyan('doctor [--full]'), 'what is wired and what actually works'],
582
994
  [cyan('off [30m] / on'), 'pause and resume tracking'],
@@ -11,22 +11,38 @@
11
11
  */
12
12
 
13
13
  import { execFileSync } from 'node:child_process';
14
- import { basename, resolve } from 'node:path';
14
+ import { basename, resolve, sep } from 'node:path';
15
15
  import { createHash } from 'node:crypto';
16
16
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
17
17
  import { dirname } from 'node:path';
18
- import { projectsFile } from '../runtime/paths.mjs';
18
+ import { projectsFile, dataDir } from '../runtime/paths.mjs';
19
19
  import { which } from '../runtime/platform.mjs';
20
20
 
21
21
  const cache = new Map();
22
22
 
23
+ /**
24
+ * Our own data directory is not a project.
25
+ *
26
+ * The shared pool is a git working copy living under dataDir, so the git-root
27
+ * rule below would classify it as somewhere the user works — and it would then
28
+ * appear in their per-project breakdown and get told off for having no
29
+ * CLAUDE.md. It is a mirror of a remote that this tool maintains, and counting
30
+ * it would be the tool measuring itself.
31
+ */
32
+ function isOurs(path) {
33
+ const root = resolve(dataDir);
34
+ return path === root || path.startsWith(root + sep);
35
+ }
36
+
23
37
  /** @returns {{id: string, name: string, root: string}|null} */
24
38
  export function projectFor(cwd) {
25
39
  if (!cwd) return null;
26
40
  const key = resolve(cwd);
27
41
  if (cache.has(key)) return cache.get(key);
42
+ if (isOurs(key)) { cache.set(key, null); return null; }
28
43
 
29
44
  const root = gitRoot(key) ?? key;
45
+ if (isOurs(resolve(root))) { cache.set(key, null); return null; }
30
46
  const project = {
31
47
  id: createHash('sha256').update(root).digest('hex').slice(0, 12),
32
48
  name: basename(root) || root,