claude-friends 0.4.0 → 0.4.2
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/cli.js +286 -65
- package/commands/friend.md +7 -1
- package/commands/friends.md +32 -1
- package/daemon.js +37 -4
- package/package.json +5 -2
- package/statusline.js +1 -1
- package/commands/nudge.md +0 -1
- package/commands/status.md +0 -1
- package/commands/unfriend.md +0 -1
package/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env -S node --no-warnings
|
|
2
2
|
|
|
3
3
|
import { writeFileSync, readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { homedir } from "os";
|
|
6
|
-
import { createInterface } from "readline";
|
|
7
6
|
import { getConfig, createConnection } from "./client.js";
|
|
8
7
|
import { fileURLToPath } from "url";
|
|
9
8
|
import { dirname } from "path";
|
|
9
|
+
import prompts from "prompts";
|
|
10
10
|
|
|
11
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
const CONFIG_PATH = join(homedir(), ".claude-friends.json");
|
|
@@ -14,6 +14,10 @@ const CONFIG_PATH = join(homedir(), ".claude-friends.json");
|
|
|
14
14
|
const command = process.argv[2];
|
|
15
15
|
const args = process.argv.slice(3).join(" ").trim();
|
|
16
16
|
|
|
17
|
+
main().catch((err) => { console.error(err.message); process.exit(1); });
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
|
|
17
21
|
// Helper: connect, send a message, wait for response, print, exit
|
|
18
22
|
function run(messageType, payload, responseType, formatter) {
|
|
19
23
|
const config = getConfig();
|
|
@@ -53,25 +57,126 @@ if (command === "setup") {
|
|
|
53
57
|
process.exit(0);
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
// Helper to check username availability
|
|
61
|
+
async function checkUsername(name) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const ws = createConnection(name);
|
|
64
|
+
const timer = setTimeout(() => { ws.close(); resolve(true); }, 5000);
|
|
65
|
+
ws.addEventListener("open", () => {
|
|
66
|
+
ws.send(JSON.stringify({ type: "check-username", username: name }));
|
|
67
|
+
});
|
|
68
|
+
ws.addEventListener("message", (event) => {
|
|
69
|
+
const msg = JSON.parse(event.data);
|
|
70
|
+
if (msg.type === "username-available") {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
ws.close();
|
|
73
|
+
resolve(msg.available);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
ws.addEventListener("error", () => { clearTimeout(timer); resolve(true); });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Helper to add a friend via server
|
|
81
|
+
async function addFriend(username, friend) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const ws = createConnection(username);
|
|
84
|
+
const timer = setTimeout(() => { ws.close(); resolve({ type: "error", message: "Timeout connecting to server." }); }, 5000);
|
|
85
|
+
ws.addEventListener("open", () => {
|
|
86
|
+
ws.send(JSON.stringify({ type: "add-friend", friend }));
|
|
87
|
+
});
|
|
88
|
+
ws.addEventListener("message", (event) => {
|
|
89
|
+
const msg = JSON.parse(event.data);
|
|
90
|
+
if (msg.type === "friend-added" || msg.type === "error") {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
ws.close();
|
|
93
|
+
resolve(msg);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
58
98
|
|
|
99
|
+
// --- Step 1: Welcome ---
|
|
59
100
|
console.log(`
|
|
60
101
|
╔══════════════════════════════════════╗
|
|
61
|
-
║
|
|
102
|
+
║ Welcome to claude-friends! ║
|
|
62
103
|
╚══════════════════════════════════════╝
|
|
104
|
+
|
|
105
|
+
See when your friends are coding in Claude Code,
|
|
106
|
+
share status updates, and nudge each other.
|
|
107
|
+
|
|
108
|
+
Friendship is mutual — you can only see someone
|
|
109
|
+
online if you've BOTH added each other.
|
|
63
110
|
`);
|
|
64
111
|
|
|
65
|
-
|
|
112
|
+
// --- Step 2: Pick a username ---
|
|
113
|
+
let username;
|
|
114
|
+
while (true) {
|
|
115
|
+
const { value } = await prompts({
|
|
116
|
+
type: "text",
|
|
117
|
+
name: "value",
|
|
118
|
+
message: "Pick a username (this is how friends will find you)",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!value) { console.log("Setup cancelled."); process.exit(0); }
|
|
66
122
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
123
|
+
const available = await checkUsername(value.trim());
|
|
124
|
+
if (!available) {
|
|
125
|
+
console.log(` "${value.trim()}" is already taken. Try another.\n`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
username = value.trim();
|
|
130
|
+
break;
|
|
70
131
|
}
|
|
71
132
|
|
|
72
|
-
|
|
133
|
+
console.log(`\n You're "${username}"!\n`);
|
|
134
|
+
|
|
135
|
+
// Save config
|
|
136
|
+
writeFileSync(CONFIG_PATH, JSON.stringify({ username }, null, 2));
|
|
137
|
+
|
|
138
|
+
// --- Step 3: Add friends ---
|
|
139
|
+
const { wantFriends } = await prompts({
|
|
140
|
+
type: "confirm",
|
|
141
|
+
name: "wantFriends",
|
|
142
|
+
message: "Want to add some friends now?",
|
|
143
|
+
initial: true,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (wantFriends) {
|
|
147
|
+
console.log("\n Tell your friends to add you back with: claude-friends add " + username + "\n");
|
|
148
|
+
|
|
149
|
+
let addMore = true;
|
|
150
|
+
while (addMore) {
|
|
151
|
+
const { friend } = await prompts({
|
|
152
|
+
type: "text",
|
|
153
|
+
name: "friend",
|
|
154
|
+
message: "Friend's username",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!friend || !friend.trim()) break;
|
|
158
|
+
|
|
159
|
+
const result = await addFriend(username, friend.trim());
|
|
160
|
+
if (result.type === "error") {
|
|
161
|
+
console.log(` ${result.message}`);
|
|
162
|
+
} else if (result.mutual) {
|
|
163
|
+
console.log(` You and ${friend.trim()} are now friends!`);
|
|
164
|
+
} else {
|
|
165
|
+
console.log(` Added! They need to add you back ("${username}") to see each other online.`);
|
|
166
|
+
}
|
|
73
167
|
|
|
74
|
-
|
|
168
|
+
const { more } = await prompts({
|
|
169
|
+
type: "confirm",
|
|
170
|
+
name: "more",
|
|
171
|
+
message: "Add another friend?",
|
|
172
|
+
initial: false,
|
|
173
|
+
});
|
|
174
|
+
addMore = more;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- Step 4: Install hooks & commands silently ---
|
|
179
|
+
// Install slash commands
|
|
75
180
|
const commandsDir = join(homedir(), ".claude", "commands");
|
|
76
181
|
mkdirSync(commandsDir, { recursive: true });
|
|
77
182
|
|
|
@@ -81,95 +186,186 @@ if (command === "setup") {
|
|
|
81
186
|
for (const file of files) {
|
|
82
187
|
copyFileSync(join(srcCommands, file), join(commandsDir, file));
|
|
83
188
|
}
|
|
84
|
-
console.log(`\nInstalled slash commands: ${files.map((f) => "/" + f.replace(".md", "")).join(", ")}`);
|
|
85
189
|
}
|
|
86
190
|
|
|
87
|
-
// Install
|
|
191
|
+
// Install hooks to settings.json
|
|
88
192
|
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
89
|
-
const hookCommand = `node ${join(__dirname, "hooks", "update-tokens.js")}`;
|
|
90
193
|
try {
|
|
91
194
|
let settings = {};
|
|
92
195
|
if (existsSync(settingsPath)) {
|
|
93
196
|
settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
94
197
|
}
|
|
95
198
|
if (!settings.hooks) settings.hooks = {};
|
|
96
|
-
if (!settings.hooks.Stop) settings.hooks.Stop = [];
|
|
97
199
|
|
|
98
|
-
|
|
200
|
+
// Token-sharing hook (Stop)
|
|
201
|
+
if (!settings.hooks.Stop) settings.hooks.Stop = [];
|
|
202
|
+
const hookCommand = `node ${join(__dirname, "hooks", "update-tokens.js")}`;
|
|
203
|
+
const tokenHookInstalled = settings.hooks.Stop.some((h) =>
|
|
99
204
|
h.hooks?.some((hk) => hk.command?.includes("update-tokens"))
|
|
100
205
|
);
|
|
101
|
-
|
|
102
|
-
if (!alreadyInstalled) {
|
|
206
|
+
if (!tokenHookInstalled) {
|
|
103
207
|
settings.hooks.Stop.push({
|
|
104
|
-
hooks: [{
|
|
105
|
-
type: "command",
|
|
106
|
-
command: hookCommand,
|
|
107
|
-
async: true,
|
|
108
|
-
}],
|
|
208
|
+
hooks: [{ type: "command", command: hookCommand, async: true }],
|
|
109
209
|
});
|
|
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
|
-
}
|
|
116
|
-
|
|
117
|
-
// Install statusline + daemon hooks (read settings once)
|
|
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
210
|
}
|
|
124
211
|
|
|
212
|
+
// Statusline
|
|
125
213
|
if (!settings.statusLine) {
|
|
126
214
|
settings.statusLine = {
|
|
127
215
|
type: "command",
|
|
128
216
|
command: `node ${join(__dirname, "statusline.js")}`,
|
|
129
217
|
};
|
|
130
|
-
console.log("Installed status line.");
|
|
131
218
|
}
|
|
132
219
|
|
|
133
|
-
//
|
|
134
|
-
if (!settings.hooks) settings.hooks = {};
|
|
135
|
-
const daemonCmd = `node ${join(__dirname, "daemon.js")}`;
|
|
136
|
-
|
|
220
|
+
// Presence daemon (SessionStart)
|
|
137
221
|
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
|
222
|
+
const daemonCmd = `node ${join(__dirname, "daemon.js")}`;
|
|
138
223
|
const daemonInstalled = settings.hooks.SessionStart.some((h) =>
|
|
139
224
|
h.hooks?.some((hk) => hk.command?.includes("daemon.js"))
|
|
140
225
|
);
|
|
141
226
|
if (!daemonInstalled) {
|
|
142
227
|
settings.hooks.SessionStart.push({
|
|
143
|
-
hooks: [{
|
|
144
|
-
type: "command",
|
|
145
|
-
command: daemonCmd,
|
|
146
|
-
async: true,
|
|
147
|
-
}],
|
|
228
|
+
hooks: [{ type: "command", command: daemonCmd, async: true }],
|
|
148
229
|
});
|
|
149
|
-
console.log("Installed presence daemon (keeps you online).");
|
|
150
230
|
}
|
|
151
231
|
|
|
152
|
-
writeFileSync(
|
|
232
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
153
233
|
} catch {}
|
|
154
234
|
|
|
235
|
+
// --- Step 5: Done ---
|
|
155
236
|
console.log(`
|
|
156
|
-
|
|
237
|
+
You're all set! In Claude Code, try:
|
|
157
238
|
|
|
158
|
-
In Claude Code:
|
|
159
|
-
/friend alice Add a friend
|
|
160
239
|
/friends See who's online
|
|
161
|
-
/
|
|
162
|
-
/
|
|
163
|
-
/
|
|
240
|
+
/friend <name> Add a friend
|
|
241
|
+
/nudge <name> Nudge someone
|
|
242
|
+
/status <message> Set your status
|
|
164
243
|
|
|
165
|
-
|
|
244
|
+
Your friends can add you with: claude-friends add ${username}
|
|
166
245
|
`);
|
|
167
246
|
|
|
168
|
-
|
|
247
|
+
} else if (command === "check-username") {
|
|
248
|
+
// Check if a username is available (for Claude Code slash commands)
|
|
249
|
+
if (!args) { console.log("Usage: claude-friends check-username <username>"); process.exit(1); }
|
|
250
|
+
const name = args.trim();
|
|
251
|
+
const ws = createConnection(name);
|
|
252
|
+
const timer = setTimeout(() => { ws.close(); console.log("available"); process.exit(0); }, 5000);
|
|
253
|
+
ws.addEventListener("open", () => {
|
|
254
|
+
ws.send(JSON.stringify({ type: "check-username", username: name }));
|
|
255
|
+
});
|
|
256
|
+
ws.addEventListener("message", (event) => {
|
|
257
|
+
const msg = JSON.parse(event.data);
|
|
258
|
+
if (msg.type === "username-available") {
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
ws.close();
|
|
261
|
+
console.log(msg.available ? "available" : "taken");
|
|
262
|
+
process.exit(msg.available ? 0 : 1);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
} else if (command === "setup-noninteractive") {
|
|
267
|
+
// Non-interactive setup for use by Claude Code slash commands
|
|
268
|
+
if (!args) { console.log("Usage: claude-friends setup-noninteractive <username>"); process.exit(1); }
|
|
269
|
+
|
|
270
|
+
const username = args.trim();
|
|
271
|
+
|
|
272
|
+
// Check if already set up
|
|
273
|
+
const existing = getConfig();
|
|
274
|
+
if (existing) {
|
|
275
|
+
console.log(`Already set up as "${existing.username}".`);
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Check username availability
|
|
280
|
+
const available = await new Promise((resolve) => {
|
|
281
|
+
const ws = createConnection(username);
|
|
282
|
+
const timer = setTimeout(() => { ws.close(); resolve(true); }, 5000);
|
|
283
|
+
ws.addEventListener("open", () => {
|
|
284
|
+
ws.send(JSON.stringify({ type: "check-username", username }));
|
|
285
|
+
});
|
|
286
|
+
ws.addEventListener("message", (event) => {
|
|
287
|
+
const msg = JSON.parse(event.data);
|
|
288
|
+
if (msg.type === "username-available") {
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
ws.close();
|
|
291
|
+
resolve(msg.available);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
ws.addEventListener("error", () => { clearTimeout(timer); resolve(true); });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (!available) {
|
|
298
|
+
console.log(`Username "${username}" is already taken.`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Register on server
|
|
303
|
+
await new Promise((resolve) => {
|
|
304
|
+
const ws = createConnection(username);
|
|
305
|
+
const timer = setTimeout(() => { ws.close(); resolve(); }, 5000);
|
|
306
|
+
ws.addEventListener("open", () => {
|
|
307
|
+
ws.send(JSON.stringify({ type: "register" }));
|
|
308
|
+
});
|
|
309
|
+
ws.addEventListener("message", (event) => {
|
|
310
|
+
const msg = JSON.parse(event.data);
|
|
311
|
+
if (msg.type === "register-result") {
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
ws.close();
|
|
314
|
+
resolve();
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Save config
|
|
320
|
+
writeFileSync(CONFIG_PATH, JSON.stringify({ username }, null, 2));
|
|
321
|
+
|
|
322
|
+
// Install slash commands
|
|
323
|
+
const commandsDir = join(homedir(), ".claude", "commands");
|
|
324
|
+
mkdirSync(commandsDir, { recursive: true });
|
|
325
|
+
const srcCommands = join(__dirname, "commands");
|
|
326
|
+
if (existsSync(srcCommands)) {
|
|
327
|
+
for (const file of readdirSync(srcCommands).filter((f) => f.endsWith(".md"))) {
|
|
328
|
+
copyFileSync(join(srcCommands, file), join(commandsDir, file));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Install hooks
|
|
333
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
334
|
+
try {
|
|
335
|
+
let settings = {};
|
|
336
|
+
if (existsSync(settingsPath)) {
|
|
337
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
338
|
+
}
|
|
339
|
+
if (!settings.hooks) settings.hooks = {};
|
|
340
|
+
|
|
341
|
+
if (!settings.hooks.Stop) settings.hooks.Stop = [];
|
|
342
|
+
const hookCommand = `node ${join(__dirname, "hooks", "update-tokens.js")}`;
|
|
343
|
+
if (!settings.hooks.Stop.some((h) => h.hooks?.some((hk) => hk.command?.includes("update-tokens")))) {
|
|
344
|
+
settings.hooks.Stop.push({ hooks: [{ type: "command", command: hookCommand, async: true }] });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!settings.statusLine) {
|
|
348
|
+
settings.statusLine = { type: "command", command: `node ${join(__dirname, "statusline.js")}` };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
|
352
|
+
const daemonCmd = `node ${join(__dirname, "daemon.js")}`;
|
|
353
|
+
if (!settings.hooks.SessionStart.some((h) => h.hooks?.some((hk) => hk.command?.includes("daemon.js")))) {
|
|
354
|
+
settings.hooks.SessionStart.push({ hooks: [{ type: "command", command: daemonCmd, async: true }] });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
358
|
+
} catch {}
|
|
359
|
+
|
|
360
|
+
console.log(`Set up as "${username}".`);
|
|
169
361
|
|
|
170
362
|
} else if (command === "add") {
|
|
171
363
|
if (!args) { console.log("Usage: claude-friends add <username>"); process.exit(1); }
|
|
172
|
-
run("add-friend", { friend: args }, "friend-added", () =>
|
|
364
|
+
run("add-friend", { friend: args }, "friend-added", (msg) =>
|
|
365
|
+
msg.mutual
|
|
366
|
+
? `You and ${args} are now friends!`
|
|
367
|
+
: `Added ${args}! They need to add you back to see each other online.`
|
|
368
|
+
);
|
|
173
369
|
|
|
174
370
|
} else if (command === "remove") {
|
|
175
371
|
if (!args) { console.log("Usage: claude-friends remove <username>"); process.exit(1); }
|
|
@@ -180,21 +376,44 @@ Token usage is shared automatically.
|
|
|
180
376
|
const friends = msg.friends || [];
|
|
181
377
|
if (friends.length === 0) return "No friends yet. Run: claude-friends add <username>";
|
|
182
378
|
|
|
183
|
-
const
|
|
379
|
+
const mutual = friends.filter((f) => f.mutual);
|
|
380
|
+
const pending = friends.filter((f) => !f.mutual);
|
|
381
|
+
const sorted = [...mutual].sort((a, b) => (b.online ? 1 : 0) - (a.online ? 1 : 0));
|
|
184
382
|
const onlineCount = sorted.filter((f) => f.online).length;
|
|
185
383
|
|
|
186
384
|
const lines = sorted.map((f) => {
|
|
187
385
|
const dot = f.online ? "🟢" : "⚫";
|
|
188
386
|
const status = f.status && f.status !== "offline" && f.status !== "unknown" ? ` — ${f.status}` : "";
|
|
189
|
-
|
|
190
|
-
? f.tokensUsed >= 1_000_000
|
|
191
|
-
? ` [${(f.tokensUsed / 1_000_000).toFixed(1)}M tokens]`
|
|
192
|
-
: ` [${(f.tokensUsed / 1000).toFixed(1)}K tokens]`
|
|
193
|
-
: "";
|
|
194
|
-
return `${dot} ${f.name}${status}${tokens}`;
|
|
387
|
+
return `${dot} ${f.name}${status}`;
|
|
195
388
|
});
|
|
196
389
|
|
|
197
|
-
|
|
390
|
+
let output = `Friends (${onlineCount}/${mutual.length} online):\n${lines.join("\n")}`;
|
|
391
|
+
|
|
392
|
+
if (pending.length > 0) {
|
|
393
|
+
output += `\n\nPending (waiting for them to add you back):\n${pending.map((f) => `⏳ ${f.name}`).join("\n")}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Token usage graph
|
|
397
|
+
const withTokens = sorted.filter((f) => f.tokensUsed > 0);
|
|
398
|
+
if (withTokens.length > 0) {
|
|
399
|
+
const maxTokens = Math.max(...withTokens.map((f) => f.tokensUsed));
|
|
400
|
+
const maxNameLen = Math.max(...withTokens.map((f) => f.name.length));
|
|
401
|
+
const barWidth = 20;
|
|
402
|
+
|
|
403
|
+
output += "\n\nToken usage today:";
|
|
404
|
+
for (const f of withTokens) {
|
|
405
|
+
const name = f.name.padEnd(maxNameLen);
|
|
406
|
+
const filled = Math.max(1, Math.round((f.tokensUsed / maxTokens) * barWidth));
|
|
407
|
+
const bar = "█".repeat(filled);
|
|
408
|
+
let tokenStr;
|
|
409
|
+
if (f.tokensUsed >= 1_000_000) tokenStr = `${(f.tokensUsed / 1_000_000).toFixed(1)}M`;
|
|
410
|
+
else if (f.tokensUsed >= 1_000) tokenStr = `${(f.tokensUsed / 1_000).toFixed(1)}K`;
|
|
411
|
+
else tokenStr = `${f.tokensUsed}`;
|
|
412
|
+
output += `\n ${name} ${bar} ${tokenStr}`;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return output;
|
|
198
417
|
});
|
|
199
418
|
|
|
200
419
|
} else if (command === "status") {
|
|
@@ -248,3 +467,5 @@ In Claude Code:
|
|
|
248
467
|
/nudge bob hey!
|
|
249
468
|
`);
|
|
250
469
|
}
|
|
470
|
+
|
|
471
|
+
} // end main
|
package/commands/friend.md
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
Add a friend by username.
|
|
2
|
+
|
|
3
|
+
First, check if claude-friends is set up by running `claude-friends whoami`. If it returns "Not set up", run the onboarding flow described in the /friends command BEFORE doing anything else.
|
|
4
|
+
|
|
5
|
+
If set up: run `claude-friends add $ARGUMENTS` and show the output. If no username is provided, ask for one using AskUserQuestion.
|
|
6
|
+
|
|
7
|
+
Remind the user that friendship is mutual — their friend needs to add them back to see each other online.
|
package/commands/friends.md
CHANGED
|
@@ -1 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
See who's online.
|
|
2
|
+
|
|
3
|
+
First, check if claude-friends is set up by running `claude-friends whoami`. If it returns "Not set up", run the onboarding flow below BEFORE doing anything else. If it IS set up, skip to "Show friends".
|
|
4
|
+
|
|
5
|
+
## Onboarding (only if not set up)
|
|
6
|
+
|
|
7
|
+
Walk the user through setup using AskUserQuestion:
|
|
8
|
+
|
|
9
|
+
1. Explain: "claude-friends lets you see when your friends are coding in Claude Code. Friendship is mutual — you can only see each other online if you've BOTH added each other."
|
|
10
|
+
|
|
11
|
+
2. Before asking the user to pick a username, generate 2-3 suggestions based on their system username (e.g. first name, initials, short nickname). Run `claude-friends check-username <name>` for EACH suggestion IN PARALLEL to check availability. Only include suggestions that return "available" as options in the AskUserQuestion. This way every option shown is guaranteed available. If the user types their own via "Other", run the check after — if taken, tell them immediately and re-ask.
|
|
12
|
+
|
|
13
|
+
3. Run `claude-friends setup-noninteractive <username>` to register the chosen username.
|
|
14
|
+
|
|
15
|
+
4. Ask "Want to add some friends?" (Yes / No). If yes, go DIRECTLY to asking for a username — do NOT show an intermediate screen. Use AskUserQuestion with two options like "Skip" and "Done adding" so the user types their friend's username via "Other". After each add:
|
|
16
|
+
- Run `claude-friends add <friend>` and show the result
|
|
17
|
+
- Ask "Add another?" (Yes / No) — repeat until they say no
|
|
18
|
+
- Remind them: "Tell your friends to add you back with: claude-friends add <their-username>"
|
|
19
|
+
|
|
20
|
+
5. Say "You're all set!" and continue to Show friends below.
|
|
21
|
+
|
|
22
|
+
## Show friends
|
|
23
|
+
|
|
24
|
+
Run `claude-friends online` and show the output.
|
|
25
|
+
|
|
26
|
+
If any friends have token usage data, also show a bar chart comparing their usage. Use block characters (█) to draw horizontal bars, scaled relative to the highest usage. Example:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
tej ██████████████████ 245.3K
|
|
30
|
+
alice ████████ 102.1K
|
|
31
|
+
bob ███ 38.5K
|
|
32
|
+
```
|
package/daemon.js
CHANGED
|
@@ -2,23 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
// Background daemon that keeps you online in claude-friends.
|
|
4
4
|
// Runs as a persistent WebSocket connection.
|
|
5
|
-
//
|
|
5
|
+
// Also periodically writes friend status to a cache file
|
|
6
|
+
// so the statusline can read it synchronously.
|
|
6
7
|
|
|
8
|
+
import { writeFileSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { homedir } from "os";
|
|
7
11
|
import { getConfig, createConnection } from "./client.js";
|
|
8
12
|
|
|
9
13
|
const config = getConfig();
|
|
10
14
|
if (!config) process.exit(0);
|
|
11
15
|
|
|
16
|
+
const CACHE_PATH = join(homedir(), ".claude-friends-online.json");
|
|
12
17
|
const ws = createConnection(config.username);
|
|
13
18
|
|
|
19
|
+
function updateCache(friends) {
|
|
20
|
+
const onlineNames = friends.filter((f) => f.online).map((f) => f.name);
|
|
21
|
+
try {
|
|
22
|
+
writeFileSync(CACHE_PATH, JSON.stringify({
|
|
23
|
+
onlineCount: onlineNames.length,
|
|
24
|
+
onlineNames,
|
|
25
|
+
lastUpdate: Date.now(),
|
|
26
|
+
}));
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
ws.addEventListener("open", () => {
|
|
15
|
-
//
|
|
31
|
+
// Request friends list immediately and periodically
|
|
32
|
+
ws.send(JSON.stringify({ type: "get-friends" }));
|
|
16
33
|
});
|
|
17
34
|
|
|
18
|
-
ws.addEventListener("
|
|
19
|
-
|
|
35
|
+
ws.addEventListener("message", (event) => {
|
|
36
|
+
try {
|
|
37
|
+
const msg = JSON.parse(event.data);
|
|
38
|
+
if (msg.type === "friends-list") {
|
|
39
|
+
updateCache(msg.friends || []);
|
|
40
|
+
}
|
|
41
|
+
// Also update on presence changes
|
|
42
|
+
if (msg.type === "presence" || msg.type === "state") {
|
|
43
|
+
ws.send(JSON.stringify({ type: "get-friends" }));
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
20
46
|
});
|
|
21
47
|
|
|
48
|
+
// Poll friends every 15 seconds
|
|
49
|
+
setInterval(() => {
|
|
50
|
+
if (ws.readyState === 1) {
|
|
51
|
+
ws.send(JSON.stringify({ type: "get-friends" }));
|
|
52
|
+
}
|
|
53
|
+
}, 15000);
|
|
54
|
+
|
|
22
55
|
// Keep process alive
|
|
23
56
|
setInterval(() => {}, 60000);
|
|
24
57
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-friends",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "See who's online in Claude Code. Add friends, share status, nudge each other.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"main": "mcp-server.js",
|
|
10
10
|
"scripts": {
|
|
11
11
|
"dev": "npx partykit dev",
|
|
12
|
-
"deploy": "npx partykit deploy"
|
|
12
|
+
"deploy": "npx partykit deploy",
|
|
13
|
+
"postinstall": "claude-friends setup"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"cli.js",
|
|
@@ -22,7 +23,9 @@
|
|
|
22
23
|
],
|
|
23
24
|
"dependencies": {
|
|
24
25
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
26
|
+
"ccstatusline": "^2.2.7",
|
|
25
27
|
"partysocket": "^1.0.3",
|
|
28
|
+
"prompts": "^2.4.2",
|
|
26
29
|
"zod": "^3.24.4"
|
|
27
30
|
},
|
|
28
31
|
"devDependencies": {
|
package/statusline.js
CHANGED
|
@@ -11,7 +11,7 @@ if (!config) {
|
|
|
11
11
|
try {
|
|
12
12
|
const friends = await queryFriends(config.username, 3000);
|
|
13
13
|
const online = friends.filter((f) => f.online);
|
|
14
|
-
const dot = online.length > 0 ? "
|
|
14
|
+
const dot = online.length > 0 ? "🟢" : "⚫";
|
|
15
15
|
const names = online.slice(0, 3).map((f) => f.name).join(", ");
|
|
16
16
|
const suffix = online.length > 3 ? "…" : "";
|
|
17
17
|
const nameStr = names ? ` (${names}${suffix})` : "";
|
package/commands/nudge.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Run `claude-friends nudge $ARGUMENTS` and show the output. If no username is provided, ask for one first.
|
package/commands/status.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Run `claude-friends status $ARGUMENTS` and show the output. If no status message is provided, ask for one first.
|
package/commands/unfriend.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Run `claude-friends remove $ARGUMENTS` and show the output. If no username is provided, ask for one first.
|