claude-friends 0.3.3 → 0.4.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/cli.js +295 -53
- package/commands/friend.md +7 -1
- package/commands/friends.md +32 -1
- package/daemon.js +60 -0
- package/package.json +7 -3
- 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
|
@@ -3,10 +3,10 @@
|
|
|
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
|
+
});
|
|
66
120
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
121
|
+
if (!value) { console.log("Setup cancelled."); process.exit(0); }
|
|
122
|
+
|
|
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
|
+
}
|
|
167
|
+
|
|
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
|
+
}
|
|
73
177
|
|
|
74
|
-
// Install
|
|
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,74 +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
210
|
}
|
|
113
|
-
} catch (err) {
|
|
114
|
-
console.log("Could not install token hook (non-critical):", err.message);
|
|
115
|
-
}
|
|
116
211
|
|
|
117
|
-
|
|
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
|
-
}
|
|
212
|
+
// Statusline
|
|
124
213
|
if (!settings.statusLine) {
|
|
125
214
|
settings.statusLine = {
|
|
126
215
|
type: "command",
|
|
127
216
|
command: `node ${join(__dirname, "statusline.js")}`,
|
|
128
217
|
};
|
|
129
|
-
writeFileSync(settingsPath2, JSON.stringify(settings, null, 2));
|
|
130
|
-
console.log("Installed status line.");
|
|
131
218
|
}
|
|
219
|
+
|
|
220
|
+
// Presence daemon (SessionStart)
|
|
221
|
+
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
|
222
|
+
const daemonCmd = `node ${join(__dirname, "daemon.js")}`;
|
|
223
|
+
const daemonInstalled = settings.hooks.SessionStart.some((h) =>
|
|
224
|
+
h.hooks?.some((hk) => hk.command?.includes("daemon.js"))
|
|
225
|
+
);
|
|
226
|
+
if (!daemonInstalled) {
|
|
227
|
+
settings.hooks.SessionStart.push({
|
|
228
|
+
hooks: [{ type: "command", command: daemonCmd, async: true }],
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
132
233
|
} catch {}
|
|
133
234
|
|
|
235
|
+
// --- Step 5: Done ---
|
|
134
236
|
console.log(`
|
|
135
|
-
|
|
237
|
+
You're all set! In Claude Code, try:
|
|
136
238
|
|
|
137
|
-
In Claude Code:
|
|
138
|
-
/friend alice Add a friend
|
|
139
239
|
/friends See who's online
|
|
140
|
-
/
|
|
141
|
-
/
|
|
142
|
-
/
|
|
240
|
+
/friend <name> Add a friend
|
|
241
|
+
/nudge <name> Nudge someone
|
|
242
|
+
/status <message> Set your status
|
|
143
243
|
|
|
144
|
-
|
|
244
|
+
Your friends can add you with: claude-friends add ${username}
|
|
145
245
|
`);
|
|
146
246
|
|
|
147
|
-
|
|
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}".`);
|
|
148
361
|
|
|
149
362
|
} else if (command === "add") {
|
|
150
363
|
if (!args) { console.log("Usage: claude-friends add <username>"); process.exit(1); }
|
|
151
|
-
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
|
+
);
|
|
152
369
|
|
|
153
370
|
} else if (command === "remove") {
|
|
154
371
|
if (!args) { console.log("Usage: claude-friends remove <username>"); process.exit(1); }
|
|
@@ -159,21 +376,44 @@ Token usage is shared automatically.
|
|
|
159
376
|
const friends = msg.friends || [];
|
|
160
377
|
if (friends.length === 0) return "No friends yet. Run: claude-friends add <username>";
|
|
161
378
|
|
|
162
|
-
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));
|
|
163
382
|
const onlineCount = sorted.filter((f) => f.online).length;
|
|
164
383
|
|
|
165
384
|
const lines = sorted.map((f) => {
|
|
166
385
|
const dot = f.online ? "🟢" : "⚫";
|
|
167
386
|
const status = f.status && f.status !== "offline" && f.status !== "unknown" ? ` — ${f.status}` : "";
|
|
168
|
-
|
|
169
|
-
? f.tokensUsed >= 1_000_000
|
|
170
|
-
? ` [${(f.tokensUsed / 1_000_000).toFixed(1)}M tokens]`
|
|
171
|
-
: ` [${(f.tokensUsed / 1000).toFixed(1)}K tokens]`
|
|
172
|
-
: "";
|
|
173
|
-
return `${dot} ${f.name}${status}${tokens}`;
|
|
387
|
+
return `${dot} ${f.name}${status}`;
|
|
174
388
|
});
|
|
175
389
|
|
|
176
|
-
|
|
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;
|
|
177
417
|
});
|
|
178
418
|
|
|
179
419
|
} else if (command === "status") {
|
|
@@ -227,3 +467,5 @@ In Claude Code:
|
|
|
227
467
|
/nudge bob hey!
|
|
228
468
|
`);
|
|
229
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
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Background daemon that keeps you online in claude-friends.
|
|
4
|
+
// Runs as a persistent WebSocket connection.
|
|
5
|
+
// Also periodically writes friend status to a cache file
|
|
6
|
+
// so the statusline can read it synchronously.
|
|
7
|
+
|
|
8
|
+
import { writeFileSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { getConfig, createConnection } from "./client.js";
|
|
12
|
+
|
|
13
|
+
const config = getConfig();
|
|
14
|
+
if (!config) process.exit(0);
|
|
15
|
+
|
|
16
|
+
const CACHE_PATH = join(homedir(), ".claude-friends-online.json");
|
|
17
|
+
const ws = createConnection(config.username);
|
|
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
|
+
|
|
30
|
+
ws.addEventListener("open", () => {
|
|
31
|
+
// Request friends list immediately and periodically
|
|
32
|
+
ws.send(JSON.stringify({ type: "get-friends" }));
|
|
33
|
+
});
|
|
34
|
+
|
|
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 {}
|
|
46
|
+
});
|
|
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
|
+
|
|
55
|
+
// Keep process alive
|
|
56
|
+
setInterval(() => {}, 60000);
|
|
57
|
+
|
|
58
|
+
// Clean exit
|
|
59
|
+
process.on("SIGINT", () => { ws.close(); process.exit(0); });
|
|
60
|
+
process.on("SIGTERM", () => { ws.close(); process.exit(0); });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-friends",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
|
@@ -17,11 +18,14 @@
|
|
|
17
18
|
"statusline.js",
|
|
18
19
|
"client.js",
|
|
19
20
|
"commands/",
|
|
20
|
-
"hooks/"
|
|
21
|
+
"hooks/",
|
|
22
|
+
"daemon.js"
|
|
21
23
|
],
|
|
22
24
|
"dependencies": {
|
|
23
25
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
26
|
+
"ccstatusline": "^2.2.7",
|
|
24
27
|
"partysocket": "^1.0.3",
|
|
28
|
+
"prompts": "^2.4.2",
|
|
25
29
|
"zod": "^3.24.4"
|
|
26
30
|
},
|
|
27
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.
|