claude-friends 0.2.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/README.md CHANGED
@@ -11,55 +11,47 @@ See who's online in Claude Code. Add friends, share what you're working on, nudg
11
11
  ```bash
12
12
  npm install -g claude-friends
13
13
  claude-friends setup
14
- claude mcp add claude-friends -- claude-friends serve
15
14
  ```
16
15
 
17
- That's it. No database, no API keys.
16
+ That's it. No database, no API keys, no MCP server to configure.
18
17
 
19
- ## What you get
18
+ ## Usage
20
19
 
21
- A status line in Claude Code showing online friends, plus these tools:
20
+ ### Slash commands (inside Claude Code)
22
21
 
23
- | Command | What it does |
24
- |---|---|
25
- | "who's online?" | See friends with 🟢/⚫ indicators |
26
- | "add friend alice" | Add someone by username |
27
- | "set my status to debugging auth" | Share what you're working on |
28
- | "nudge bob" | Poke a friend with a message |
29
- | "share my token usage: 45000" | Let friends see your token count |
30
- | "check nudges" | See if anyone poked you |
31
-
32
- ## Status line
22
+ ```
23
+ /friend alice Add a friend
24
+ /friends See who's online
25
+ /nudge bob hey! Nudge someone
26
+ /status debugging Set your status
27
+ /unfriend alice Remove a friend
28
+ ```
33
29
 
34
- Add to your Claude Code settings (`~/.claude/settings.json`):
30
+ ### CLI (from any terminal)
35
31
 
36
- ```json
37
- {
38
- "statusLine": {
39
- "type": "command",
40
- "command": "node /path/to/claude-friends/statusline.js"
41
- }
42
- }
32
+ ```bash
33
+ claude-friends add alice
34
+ claude-friends online
35
+ claude-friends nudge bob "ship it!"
36
+ claude-friends status "pair programming"
37
+ claude-friends remove alice
43
38
  ```
44
39
 
45
- Or after global install:
40
+ ## Features
46
41
 
47
- ```json
48
- {
49
- "statusLine": {
50
- "type": "command",
51
- "command": "claude-friends statusline"
52
- }
53
- }
54
- ```
42
+ - **Online presence** — see who's in Claude Code right now
43
+ - **Status messages** — share what you're working on
44
+ - **Nudges** — poke a friend with a message
45
+ - **Token usage** — automatically shared with friends (opt-in via hook)
46
+ - **Status line** — friend count shown in Claude Code's bottom bar
55
47
 
56
48
  ## How it works
57
49
 
58
50
  - **PartyKit** handles real-time presence via WebSockets
59
- - When you open Claude Code → you go online
60
- - When you close it PartyKit detects the disconnect → you go offline
61
- - Friend lists and nudges are stored in-memory on the server
51
+ - When you use Claude Code → you appear online
52
+ - Friend lists and nudges are stored on the server
62
53
  - No accounts, no passwords — just a username
54
+ - Setup auto-installs slash commands, status line, and token-sharing hook
63
55
 
64
56
  ## Self-hosting
65
57
 
package/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { writeFileSync, existsSync, mkdirSync, copyFileSync, readdirSync } from "fs";
3
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync } from "fs";
4
4
  import { join } from "path";
5
5
  import { homedir } from "os";
6
6
  import { createInterface } from "readline";
7
- import { getConfig } from "./client.js";
7
+ import { getConfig, createConnection } from "./client.js";
8
8
  import { fileURLToPath } from "url";
9
9
  import { dirname } from "path";
10
10
 
@@ -12,6 +12,37 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  const CONFIG_PATH = join(homedir(), ".claude-friends.json");
13
13
 
14
14
  const command = process.argv[2];
15
+ const args = process.argv.slice(3).join(" ").trim();
16
+
17
+ // Helper: connect, send a message, wait for response, print, exit
18
+ function run(messageType, payload, responseType, formatter) {
19
+ const config = getConfig();
20
+ if (!config) {
21
+ console.log("Not set up. Run: claude-friends setup");
22
+ process.exit(1);
23
+ }
24
+
25
+ const ws = createConnection(config.username);
26
+
27
+ ws.addEventListener("open", () => {
28
+ ws.send(JSON.stringify({ type: messageType, ...payload }));
29
+ });
30
+
31
+ ws.addEventListener("message", (event) => {
32
+ const msg = JSON.parse(event.data);
33
+ if (msg.type === responseType || msg.type === "error") {
34
+ if (msg.type === "error") {
35
+ console.log("Error:", msg.message);
36
+ } else {
37
+ console.log(formatter(msg));
38
+ }
39
+ ws.close();
40
+ process.exit(0);
41
+ }
42
+ });
43
+
44
+ setTimeout(() => { console.log("Timeout connecting to server."); process.exit(1); }, 5000);
45
+ }
15
46
 
16
47
  if (command === "setup") {
17
48
  const existing = getConfig();
@@ -53,43 +84,142 @@ if (command === "setup") {
53
84
  console.log(`\nInstalled slash commands: ${files.map((f) => "/" + f.replace(".md", "")).join(", ")}`);
54
85
  }
55
86
 
56
- console.log(`
57
- Done! You're "${username.trim()}".
87
+ // Install token-sharing hook to ~/.claude/settings.json
88
+ const settingsPath = join(homedir(), ".claude", "settings.json");
89
+ const hookCommand = `node ${join(__dirname, "hooks", "update-tokens.js")}`;
90
+ try {
91
+ let settings = {};
92
+ if (existsSync(settingsPath)) {
93
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
94
+ }
95
+ if (!settings.hooks) settings.hooks = {};
96
+ if (!settings.hooks.Stop) settings.hooks.Stop = [];
97
+
98
+ const alreadyInstalled = settings.hooks.Stop.some((h) =>
99
+ h.hooks?.some((hk) => hk.command?.includes("update-tokens"))
100
+ );
101
+
102
+ if (!alreadyInstalled) {
103
+ settings.hooks.Stop.push({
104
+ hooks: [{
105
+ type: "command",
106
+ command: hookCommand,
107
+ async: true,
108
+ }],
109
+ });
110
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
111
+ console.log("Installed auto token-sharing hook.");
112
+ }
113
+ } catch (err) {
114
+ console.log("Could not install token hook (non-critical):", err.message);
115
+ }
58
116
 
59
- Now add the MCP server to Claude Code:
117
+ // Install statusline
118
+ try {
119
+ const settingsPath2 = join(homedir(), ".claude", "settings.json");
120
+ let settings = {};
121
+ if (existsSync(settingsPath2)) {
122
+ settings = JSON.parse(readFileSync(settingsPath2, "utf-8"));
123
+ }
124
+ if (!settings.statusLine) {
125
+ settings.statusLine = {
126
+ type: "command",
127
+ command: `node ${join(__dirname, "statusline.js")}`,
128
+ };
129
+ writeFileSync(settingsPath2, JSON.stringify(settings, null, 2));
130
+ console.log("Installed status line.");
131
+ }
132
+ } catch {}
60
133
 
61
- claude mcp add claude-friends -- claude-friends serve
134
+ console.log(`
135
+ Done! You're "${username.trim()}".
62
136
 
63
- Then in Claude Code:
137
+ In Claude Code:
64
138
  /friend alice Add a friend
65
139
  /friends See who's online
66
- /nudge bob Nudge someone
140
+ /nudge bob hey! Nudge someone
67
141
  /status debugging Set your status
68
142
  /unfriend alice Remove a friend
143
+
144
+ Token usage is shared automatically.
69
145
  `);
70
146
 
71
147
  rl.close();
72
- } else if (command === "serve") {
73
- // Start the MCP server directly
74
- await import("./mcp-server.js");
148
+
149
+ } else if (command === "add") {
150
+ if (!args) { console.log("Usage: claude-friends add <username>"); process.exit(1); }
151
+ run("add-friend", { friend: args }, "friend-added", () => `Added ${args} as a friend!`);
152
+
153
+ } else if (command === "remove") {
154
+ if (!args) { console.log("Usage: claude-friends remove <username>"); process.exit(1); }
155
+ run("remove-friend", { friend: args }, "friend-removed", () => `Removed ${args}.`);
156
+
157
+ } else if (command === "online" || command === "list") {
158
+ run("get-friends", {}, "friends-list", (msg) => {
159
+ const friends = msg.friends || [];
160
+ if (friends.length === 0) return "No friends yet. Run: claude-friends add <username>";
161
+
162
+ const sorted = [...friends].sort((a, b) => (b.online ? 1 : 0) - (a.online ? 1 : 0));
163
+ const onlineCount = sorted.filter((f) => f.online).length;
164
+
165
+ const lines = sorted.map((f) => {
166
+ const dot = f.online ? "🟢" : "⚫";
167
+ const status = f.status && f.status !== "offline" && f.status !== "unknown" ? ` — ${f.status}` : "";
168
+ const tokens = f.tokensUsed ? ` [${(f.tokensUsed / 1000).toFixed(1)}K tokens]` : "";
169
+ return `${dot} ${f.name}${status}${tokens}`;
170
+ });
171
+
172
+ return `Friends (${onlineCount}/${friends.length} online):\n${lines.join("\n")}`;
173
+ });
174
+
175
+ } else if (command === "status") {
176
+ if (!args) { console.log("Usage: claude-friends status <message>"); process.exit(1); }
177
+ const config = getConfig();
178
+ if (!config) { console.log("Not set up. Run: claude-friends setup"); process.exit(1); }
179
+ const ws = createConnection(config.username);
180
+ ws.addEventListener("open", () => {
181
+ ws.send(JSON.stringify({ type: "set-status", status: args }));
182
+ console.log(`Status set: "${args}"`);
183
+ setTimeout(() => { ws.close(); process.exit(0); }, 500);
184
+ });
185
+ setTimeout(() => process.exit(1), 5000);
186
+
187
+ } else if (command === "nudge") {
188
+ const parts = args.split(" ");
189
+ const friend = parts[0];
190
+ const message = parts.slice(1).join(" ") || undefined;
191
+ if (!friend) { console.log("Usage: claude-friends nudge <username> [message]"); process.exit(1); }
192
+ run("nudge", { friend, message }, "nudge-sent", () => `Nudge sent to ${friend}!`);
193
+
75
194
  } else if (command === "whoami") {
76
195
  const config = getConfig();
77
- if (!config) {
78
- console.log("Not set up yet. Run: claude-friends setup");
79
- } else {
80
- console.log(config.username);
81
- }
196
+ if (!config) { console.log("Not set up. Run: claude-friends setup"); process.exit(1); }
197
+ console.log(config.username);
198
+
199
+ } else if (command === "serve") {
200
+ // Keep for backwards compat with anyone who set up MCP
201
+ await import("./mcp-server.js");
202
+
82
203
  } else {
83
204
  console.log(`
84
205
  claude-friends — social presence for Claude Code
85
206
 
86
207
  Commands:
87
- setup Pick a username (one-time)
88
- serve Start the MCP server (used by Claude Code)
89
- whoami Show your username
208
+ setup Pick a username (one-time)
209
+ add <username> Add a friend
210
+ remove <username> Remove a friend
211
+ online See who's online
212
+ status <message> Set your status
213
+ nudge <user> [msg] Nudge a friend
214
+ whoami Show your username
90
215
 
91
216
  Quick start:
92
217
  claude-friends setup
93
- claude mcp add claude-friends -- claude-friends serve
218
+ claude-friends add alice
219
+
220
+ In Claude Code:
221
+ /friend alice
222
+ /friends
223
+ /nudge bob hey!
94
224
  `);
95
225
  }
@@ -1 +1 @@
1
- Use the add-friend MCP tool to add "$ARGUMENTS" as a friend. If no username is provided, ask for one.
1
+ Run `claude-friends add $ARGUMENTS` and show the output. If no username is provided, ask for one first.
@@ -1 +1 @@
1
- Use the friends-online MCP tool to show who's currently online.
1
+ Run `claude-friends online` and show the output.
package/commands/nudge.md CHANGED
@@ -1 +1 @@
1
- Use the nudge MCP tool to nudge "$ARGUMENTS". If the input includes a message after the username, pass it as the message parameter. If no username is provided, ask for one.
1
+ Run `claude-friends nudge $ARGUMENTS` and show the output. If no username is provided, ask for one first.
@@ -1 +1 @@
1
- Use the set-status MCP tool to set your status to "$ARGUMENTS". If no status is provided, ask what to set it to.
1
+ Run `claude-friends status $ARGUMENTS` and show the output. If no status message is provided, ask for one first.
@@ -1 +1 @@
1
- Use the remove-friend MCP tool to remove "$ARGUMENTS" from your friends list. If no username is provided, ask for one.
1
+ Run `claude-friends remove $ARGUMENTS` and show the output. If no username is provided, ask for one first.
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Hook script: reads token estimate from session, pushes to PartyKit
4
+ // Called by Claude Code's Stop hook after each response
5
+
6
+ import { readFileSync, writeFileSync, existsSync } from "fs";
7
+ import { join } from "path";
8
+ import { homedir } from "os";
9
+
10
+ const CONFIG_PATH = join(homedir(), ".claude-friends.json");
11
+ const TOKENS_PATH = join(homedir(), ".claude-friends-tokens.json");
12
+ const PARTY_HOST = "claude-friends-app.nandinitalwar.partykit.dev";
13
+
14
+ // Read config
15
+ let config;
16
+ try {
17
+ config = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
18
+ } catch {
19
+ process.exit(0); // silently exit if not set up
20
+ }
21
+
22
+ // Read hook input from stdin
23
+ let input = "";
24
+ try {
25
+ input = readFileSync("/dev/stdin", "utf-8");
26
+ } catch {}
27
+
28
+ // Parse the stop event to estimate tokens
29
+ let tokensThisCall = 0;
30
+ try {
31
+ const event = JSON.parse(input);
32
+ // Estimate based on content length — rough but functional
33
+ const content = JSON.stringify(event);
34
+ tokensThisCall = Math.ceil(content.length / 4); // ~4 chars per token
35
+ } catch {
36
+ tokensThisCall = 500; // default estimate per response
37
+ }
38
+
39
+ // Accumulate session tokens
40
+ let sessionTokens = 0;
41
+ try {
42
+ if (existsSync(TOKENS_PATH)) {
43
+ const data = JSON.parse(readFileSync(TOKENS_PATH, "utf-8"));
44
+ // Reset if older than 4 hours (new session)
45
+ if (Date.now() - data.lastUpdate < 4 * 60 * 60 * 1000) {
46
+ sessionTokens = data.tokens || 0;
47
+ }
48
+ }
49
+ } catch {}
50
+
51
+ sessionTokens += tokensThisCall;
52
+
53
+ writeFileSync(TOKENS_PATH, JSON.stringify({
54
+ tokens: sessionTokens,
55
+ lastUpdate: Date.now(),
56
+ }));
57
+
58
+ // Push to PartyKit via HTTP (faster than WebSocket for one-shot)
59
+ // Use the WebSocket approach since PartyKit is WS-only
60
+ try {
61
+ const { default: PartySocket } = await import("partysocket");
62
+ const ws = new PartySocket({
63
+ host: PARTY_HOST,
64
+ room: "lobby",
65
+ query: { username: config.username },
66
+ });
67
+
68
+ ws.addEventListener("open", () => {
69
+ ws.send(JSON.stringify({ type: "share-tokens", tokens: sessionTokens }));
70
+ setTimeout(() => { ws.close(); process.exit(0); }, 500);
71
+ });
72
+
73
+ ws.addEventListener("error", () => process.exit(0));
74
+
75
+ // Don't hang
76
+ setTimeout(() => process.exit(0), 3000);
77
+ } catch {
78
+ process.exit(0);
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-friends",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "See who's online in Claude Code. Add friends, share status, nudge each other.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,8 @@
16
16
  "mcp-server.js",
17
17
  "statusline.js",
18
18
  "client.js",
19
- "commands/"
19
+ "commands/",
20
+ "hooks/"
20
21
  ],
21
22
  "dependencies": {
22
23
  "@modelcontextprotocol/sdk": "^1.12.1",