@ramarivera/coding-buddy 0.9.4 → 0.9.5
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/cli/install.ts +32 -6
- package/cli/runtime-app.ts +157 -5
- package/package.json +4 -1
- package/runtime.bun.lock +1389 -0
- package/server/index.ts +22 -21
- package/server/react-args.ts +64 -0
- package/skills/buddy/SKILL.md +19 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi (requires bun: https://bun.sh/install)",
|
|
8
|
-
"version": "0.9.
|
|
8
|
+
"version": "0.9.5"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "coding-buddy",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality. Requires bun runtime (https://bun.sh/install).",
|
|
15
|
-
"version": "0.9.
|
|
15
|
+
"version": "0.9.5",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "ramarivera"
|
|
18
18
|
},
|
package/cli/install.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* ~/.claude/ layout when the env var is unset.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync } from "fs";
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync, rmSync } from "fs";
|
|
11
11
|
import { execFileSync, execSync } from "child_process";
|
|
12
12
|
import { resolve, dirname, join } from "path";
|
|
13
13
|
|
|
@@ -22,7 +22,12 @@ import {
|
|
|
22
22
|
} from "../server/path.ts";
|
|
23
23
|
import { loadCompanion, saveCompanion, resolveUserId, writeStatusState } from "../server/state.ts";
|
|
24
24
|
import { generateFallbackName } from "../core/reactions.ts"
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
installRuntimeAppAtomically,
|
|
27
|
+
redactSecrets,
|
|
28
|
+
stableRuntimePaths,
|
|
29
|
+
supportsTextLockfile,
|
|
30
|
+
} from "./runtime-app.ts";
|
|
26
31
|
|
|
27
32
|
/** Recognise any buddy-owned hook command, however it is pathed. */
|
|
28
33
|
function isBuddyHookCommand(command: unknown): boolean {
|
|
@@ -261,10 +266,31 @@ function installHooks(settings: Record<string, any>, appDir: string) {
|
|
|
261
266
|
}
|
|
262
267
|
|
|
263
268
|
function installRuntimeApp(): string {
|
|
264
|
-
const appDir =
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
269
|
+
const appDir = installRuntimeAppAtomically(PROJECT_ROOT, buddyStateDir(), (stagingDir) => {
|
|
270
|
+
const installArgs = ["install", "--production", "--ignore-scripts"];
|
|
271
|
+
const lockfile = join(stagingDir, "bun.lock");
|
|
272
|
+
if (existsSync(lockfile)) {
|
|
273
|
+
const bunVersion = execFileSync("bun", ["--version"], { encoding: "utf8" }).trim();
|
|
274
|
+
if (supportsTextLockfile(bunVersion)) {
|
|
275
|
+
installArgs.push("--frozen-lockfile");
|
|
276
|
+
} else {
|
|
277
|
+
rmSync(lockfile, { force: true });
|
|
278
|
+
warn(`Bun ${bunVersion} cannot read bun.lock (needs 1.2 or newer); installing dependencies unpinned. Upgrade Bun for a reproducible install.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
execFileSync("bun", installArgs, { cwd: stagingDir, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
|
|
283
|
+
} catch (error) {
|
|
284
|
+
// Show bun's own output: without it a dependency failure reads as a bare
|
|
285
|
+
// "Command failed" and the user cannot tell what broke. The original
|
|
286
|
+
// error is not attached as `cause` because it carries the raw output.
|
|
287
|
+
const failure = error as { stdout?: string; stderr?: string };
|
|
288
|
+
const output = redactSecrets([failure.stdout, failure.stderr].filter(Boolean).join("\n").trim());
|
|
289
|
+
throw new Error(
|
|
290
|
+
`bun ${installArgs.join(" ")} failed${output ? `:\n${output}` : ""}\nThe previous runtime app, if any, was left in place.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
268
294
|
ok(`Stable runtime installed: ${appDir}`);
|
|
269
295
|
return appDir;
|
|
270
296
|
}
|
package/cli/runtime-app.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, rmSync, renameSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
3
4
|
|
|
4
5
|
export const RUNTIME_DIRECTORIES = [
|
|
5
6
|
"core",
|
|
@@ -11,6 +12,13 @@ export const RUNTIME_DIRECTORIES = [
|
|
|
11
12
|
|
|
12
13
|
export const RUNTIME_FILES = ["package.json", "bun.lock"] as const;
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* npm never packs a file named bun.lock, so the published tarball carries the
|
|
17
|
+
* lockfile as runtime.bun.lock (written by the prepack script). A git checkout
|
|
18
|
+
* has the real bun.lock instead.
|
|
19
|
+
*/
|
|
20
|
+
export const PACKED_LOCKFILE = "runtime.bun.lock";
|
|
21
|
+
|
|
14
22
|
export function stableRuntimeAppDir(stateDir: string): string {
|
|
15
23
|
return join(stateDir, "app");
|
|
16
24
|
}
|
|
@@ -25,12 +33,28 @@ export function stableRuntimePaths(appDir: string) {
|
|
|
25
33
|
};
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
export const STAGING_PREFIX = "app.staging-";
|
|
37
|
+
|
|
28
38
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
39
|
+
* A staging directory unique to this install, so two installers running at
|
|
40
|
+
* once (a manual run and a post-apply hook, say) never delete each other's
|
|
41
|
+
* in-progress dependency install.
|
|
31
42
|
*/
|
|
32
|
-
export function
|
|
33
|
-
|
|
43
|
+
export function stagingRuntimeAppDir(stateDir: string): string {
|
|
44
|
+
return join(stateDir, `${STAGING_PREFIX}${process.pid}-${randomUUID().slice(0, 8)}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Write a fresh runtime copy into `targetDir`, the stable app directory by
|
|
49
|
+
* default. The directory is owned by the installer, so removing it first also
|
|
50
|
+
* removes files deleted by an upgrade.
|
|
51
|
+
*/
|
|
52
|
+
export function copyRuntimeApp(
|
|
53
|
+
sourceRoot: string,
|
|
54
|
+
stateDir: string,
|
|
55
|
+
targetDir: string = stableRuntimeAppDir(stateDir),
|
|
56
|
+
): string {
|
|
57
|
+
const appDir = targetDir;
|
|
34
58
|
rmSync(appDir, { recursive: true, force: true });
|
|
35
59
|
mkdirSync(appDir, { recursive: true });
|
|
36
60
|
|
|
@@ -45,5 +69,133 @@ export function copyRuntimeApp(sourceRoot: string, stateDir: string): string {
|
|
|
45
69
|
if (existsSync(source)) cpSync(source, join(appDir, file), { force: true });
|
|
46
70
|
}
|
|
47
71
|
|
|
72
|
+
const packedLock = join(sourceRoot, PACKED_LOCKFILE);
|
|
73
|
+
const appLock = join(appDir, "bun.lock");
|
|
74
|
+
if (!existsSync(appLock) && existsSync(packedLock)) cpSync(packedLock, appLock);
|
|
75
|
+
|
|
48
76
|
return appDir;
|
|
49
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the runtime app next to the live one and swap it in only after
|
|
81
|
+
* `prepare` (the dependency install) succeeds. A failed install then leaves
|
|
82
|
+
* the previous working app, and the MCP registration that points at it,
|
|
83
|
+
* untouched.
|
|
84
|
+
*/
|
|
85
|
+
export function installRuntimeAppAtomically(
|
|
86
|
+
sourceRoot: string,
|
|
87
|
+
stateDir: string,
|
|
88
|
+
prepare: (stagingDir: string) => void,
|
|
89
|
+
): string {
|
|
90
|
+
const appDir = stableRuntimeAppDir(stateDir);
|
|
91
|
+
const stagingDir = stagingRuntimeAppDir(stateDir);
|
|
92
|
+
try {
|
|
93
|
+
copyRuntimeApp(sourceRoot, stateDir, stagingDir);
|
|
94
|
+
prepare(stagingDir);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
promoteStagedApp(stagingDir, appDir);
|
|
100
|
+
return appDir;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const PROMOTE_ATTEMPTS = 3;
|
|
104
|
+
export const PREVIOUS_PREFIX = "app.previous-";
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Swap the staged app into place without ever leaving the user with no app.
|
|
108
|
+
*
|
|
109
|
+
* The live app is moved aside, not deleted, and is moved back if the staged
|
|
110
|
+
* app cannot take its place. Another installer can recreate `appDir` in the
|
|
111
|
+
* middle of the swap, so the swap retries; if it still cannot rename and a
|
|
112
|
+
* complete app from that other installer is in place, that app is kept.
|
|
113
|
+
* Staging and moved-aside copies are removed on every path.
|
|
114
|
+
*/
|
|
115
|
+
export function promoteStagedApp(
|
|
116
|
+
stagingDir: string,
|
|
117
|
+
appDir: string,
|
|
118
|
+
rename: (from: string, to: string) => void = renameSync,
|
|
119
|
+
): void {
|
|
120
|
+
const backups: string[] = [];
|
|
121
|
+
let lastError: unknown;
|
|
122
|
+
// True only when our move-in failed because an app we did not put there was
|
|
123
|
+
// in the way: another installer finished between our two renames.
|
|
124
|
+
let displacedByOther = false;
|
|
125
|
+
// Where the previous app was left if it could be neither replaced nor put
|
|
126
|
+
// back. Kept across retries so the final error can always name it.
|
|
127
|
+
let stranded: string | undefined;
|
|
128
|
+
|
|
129
|
+
for (let attempt = 0; attempt < PROMOTE_ATTEMPTS; attempt++) {
|
|
130
|
+
const backup = `${appDir}.previous-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
131
|
+
let movedAside = false;
|
|
132
|
+
let promoting = false;
|
|
133
|
+
displacedByOther = false;
|
|
134
|
+
try {
|
|
135
|
+
if (existsSync(appDir)) {
|
|
136
|
+
backups.push(backup);
|
|
137
|
+
rename(appDir, backup);
|
|
138
|
+
movedAside = true;
|
|
139
|
+
}
|
|
140
|
+
promoting = true;
|
|
141
|
+
rename(stagingDir, appDir);
|
|
142
|
+
for (const dir of backups) rmSync(dir, { recursive: true, force: true });
|
|
143
|
+
if (stranded) rmSync(stranded, { recursive: true, force: true });
|
|
144
|
+
return;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
lastError = error;
|
|
147
|
+
displacedByOther = promoting && existsSync(appDir);
|
|
148
|
+
if (movedAside && !existsSync(appDir)) {
|
|
149
|
+
try {
|
|
150
|
+
rename(backup, appDir);
|
|
151
|
+
} catch {
|
|
152
|
+
// Leave the moved-aside copy where it is; the final error names it.
|
|
153
|
+
backups.splice(backups.indexOf(backup), 1);
|
|
154
|
+
stranded = backup;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
161
|
+
for (const dir of backups) rmSync(dir, { recursive: true, force: true });
|
|
162
|
+
// Another installer's complete app is in place: keep it. Every other
|
|
163
|
+
// failure, including never getting the old app out of the way, is an error.
|
|
164
|
+
if (displacedByOther && existsSync(stableRuntimePaths(appDir).mcpServer)) {
|
|
165
|
+
if (stranded) rmSync(stranded, { recursive: true, force: true });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (stranded) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Could not install the new runtime app or put the previous one back. The previous app is at ${stranded}; rename it to ${appDir} to recover.`,
|
|
171
|
+
{ cause: lastError },
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
throw lastError;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* bun.lock is Bun's text lockfile, which Bun reads from 1.2.0 on. Older Bun
|
|
179
|
+
* rejects it under --frozen-lockfile, so the installer drops the lockfile for
|
|
180
|
+
* those versions and installs unpinned, as it did before the lockfile shipped.
|
|
181
|
+
*/
|
|
182
|
+
export function supportsTextLockfile(bunVersion: string): boolean {
|
|
183
|
+
const match = /^(\d+)\.(\d+)\./.exec(bunVersion.trim());
|
|
184
|
+
if (!match) return false;
|
|
185
|
+
const major = Number(match[1]);
|
|
186
|
+
const minor = Number(match[2]);
|
|
187
|
+
return major > 1 || (major === 1 && minor >= 2);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Strip registry credentials from tool output before it is shown. Bun before
|
|
192
|
+
* 1.1.34 could echo an .npmrc token in a warning.
|
|
193
|
+
*/
|
|
194
|
+
export function redactSecrets(text: string): string {
|
|
195
|
+
return text
|
|
196
|
+
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1[redacted]@")
|
|
197
|
+
.replace(/(_authToken|_auth|_password)\s*=\s*\S+/gi, "$1=[redacted]")
|
|
198
|
+
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/g, "$1 [redacted]")
|
|
199
|
+
.replace(/\bnpm_[A-Za-z0-9]{20,}\b/g, "[redacted]");
|
|
200
|
+
}
|
|
201
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ramarivera/coding-buddy",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"omp": {
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"gen:emoji-widths": "bun run scripts/gen-emoji-widths.ts",
|
|
39
39
|
"sync-version": "bun run scripts/sync-version.ts",
|
|
40
40
|
"version": "bun run sync-version && git add .claude-plugin",
|
|
41
|
+
"prepack": "node -e \"require('fs').copyFileSync('bun.lock','runtime.bun.lock')\"",
|
|
42
|
+
"postpack": "node -e \"require('fs').rmSync('runtime.bun.lock',{force:true})\"",
|
|
41
43
|
"ci:statusline": "bash scripts/ci/check-statusline.sh",
|
|
42
44
|
"ci:vhs:render": "bash scripts/ci/render-vhs-goldens.sh",
|
|
43
45
|
"ci:vhs:update": "bash scripts/ci/render-vhs-goldens.sh --update",
|
|
@@ -53,6 +55,7 @@
|
|
|
53
55
|
"statusline/",
|
|
54
56
|
"scripts/",
|
|
55
57
|
".claude-plugin/",
|
|
58
|
+
"runtime.bun.lock",
|
|
56
59
|
"!**/*.test.ts"
|
|
57
60
|
],
|
|
58
61
|
"keywords": [
|