codebay 0.12.1 → 0.14.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/.mochi/manifest.json +127 -111
- package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +1 -0
- package/.mochi/svelte-client/{_hydrate-AppShell_2rtnxn6pgp2rh-5kb6kh2d.js → _hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js} +2 -2
- package/.mochi/svelte-client/{_hydrate-AvatarsView_257s9yq1wwr4f-c51gt15m.js → _hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js} +1 -1
- package/.mochi/svelte-client/{_hydrate-InstanceView_2ma45l9bybfhl-d256bw54.js → _hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js} +1 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +40 -0
- package/.mochi/svelte-client/{chunk-2eaagsfc.js → chunk-0mc09cyb.js} +1 -1
- package/.mochi/svelte-client/{chunk-a5e8vytv.js → chunk-5jwfs4p9.js} +2 -2
- package/.mochi/svelte-client/chunk-6qg4q98a.js +2 -0
- package/.mochi/svelte-client/{chunk-d8rdrqsh.js → chunk-ccfh8hxz.js} +1 -1
- package/.mochi/svelte-client/{chunk-d9bszcp8.js → chunk-phf7v5jj.js} +1 -1
- package/.mochi/svelte-client/{chunk-y1xjps59.js → chunk-spk1b2r4.js} +1 -1
- package/.mochi/svelte-client/chunk-tmeh70wg.js +2 -0
- package/.mochi/svelte-client/{chunk-6v5d7dex.js → chunk-vs1yjbqt.js} +1 -1
- package/.mochi/svelte-client/{chunk-1ttqgrz7.js → chunk-x0m3k0rn.js} +1 -1
- package/.mochi/svelte-client/{chunk-s70rry6z.js → chunk-y7f4jbdm.js} +1 -1
- package/.mochi/svelte-compile/{App-dn3kh0xn.server.js → App-94xsdn0w.server.js} +2 -2
- package/.mochi/svelte-compile/{Avatars-7k5055m8.server.js → Avatars-fnccw47h.server.js} +1 -1
- package/.mochi/svelte-compile/{Instance-hj76qnha.server.js → Instance-knnkcn2p.server.js} +1 -1
- package/.mochi/svelte-compile/{Settings-m2bfa4k4.server.js → Settings-a7b11v90.server.js} +274 -142
- package/.mochi/svelte-compile/{chunk-qc0f73f3.js → chunk-apdte4nh.js} +7 -8
- package/.mochi/svelte-compile/{chunk-zd49axkf.js → chunk-w0hca7va.js} +2 -1
- package/package.json +1 -1
- package/public/favicon.svg +9 -0
- package/src/avatars/bee.ts +7 -8
- package/src/components/SettingsView.svelte +148 -0
- package/src/container-injections/claude-code-credentials.ts +2 -3
- package/src/container-injections/claude-code-install.ts +12 -7
- package/src/container-injections/claude-code-skills.ts +125 -0
- package/src/container-injections/claude-code-update.ts +45 -10
- package/src/container-injections/claude-output-style.ts +83 -0
- package/src/container-injections/claude-statusline.ts +5 -16
- package/src/lib/container-files.server.ts +22 -0
- package/src/lib/host-claude.server.ts +31 -0
- package/src/lib/injections.server.ts +7 -1
- package/src/pages/Settings.svelte +12 -1
- package/src/routes.ts +19 -0
- package/src/shell.html +1 -0
- package/src/types.ts +16 -0
- package/.mochi/svelte-client/HydratableIsland-4j3bazeq.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-rw5z89pa.js +0 -39
- package/.mochi/svelte-client/chunk-pz6dq0v3.js +0 -2
- package/.mochi/svelte-client/chunk-wcfbjj3x.js +0 -2
|
@@ -70,16 +70,15 @@ var bear_default = art4;
|
|
|
70
70
|
// src/avatars/bee.ts
|
|
71
71
|
var art5 = {
|
|
72
72
|
name: "bee",
|
|
73
|
-
robot: true,
|
|
74
73
|
pixels: [
|
|
75
|
-
".#....#.",
|
|
76
|
-
"##.##.##",
|
|
77
|
-
"#.####.#",
|
|
78
|
-
".######.",
|
|
79
|
-
".######.",
|
|
80
74
|
"........",
|
|
81
|
-
"
|
|
82
|
-
"
|
|
75
|
+
"...++##.",
|
|
76
|
+
"..++###.",
|
|
77
|
+
"...+#...",
|
|
78
|
+
".#####..",
|
|
79
|
+
"#.+#+###",
|
|
80
|
+
"##+#+##.",
|
|
81
|
+
".#####.."
|
|
83
82
|
]
|
|
84
83
|
};
|
|
85
84
|
var bee_default = art5;
|
|
@@ -11,6 +11,7 @@ var CLAUDE_PERMISSION_MODES = [
|
|
|
11
11
|
"plan"
|
|
12
12
|
];
|
|
13
13
|
var CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
14
|
+
var CLAUDE_OUTPUT_STYLES = ["default", "none", "Concise"];
|
|
14
15
|
function ideUrl(instance) {
|
|
15
16
|
const base = `/p/${instance.id}/`;
|
|
16
17
|
return instance.remote_workspace_folder ? `${base}?folder=${encodeURIComponent(instance.remote_workspace_folder)}` : base;
|
|
@@ -62,4 +63,4 @@ function applyTheme(theme) {
|
|
|
62
63
|
document.documentElement.dataset.theme = theme;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
export { CLAUDE_PERMISSION_MODES, CLAUDE_EFFORT_LEVELS, ideUrl, apiFetch, apiPost, apiDelete, getTheme, setTheme, applyTheme };
|
|
66
|
+
export { CLAUDE_PERMISSION_MODES, CLAUDE_EFFORT_LEVELS, CLAUDE_OUTPUT_STYLES, ideUrl, apiFetch, apiPost, apiDelete, getTheme, setTheme, applyTheme };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codebay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Web UI for spinning up isolated devcontainer instances from any local folder, each with a browser-based VS Code (code-server) — built for parallel Claude Code workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
|
2
|
+
<rect x="0" y="0" width="24" height="24" fill="#14150f" />
|
|
3
|
+
<g fill="none" stroke="#d7d8d2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
4
|
+
<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />
|
|
5
|
+
<path d="M12 22V12" />
|
|
6
|
+
<polyline points="3.29 7 12 12 20.71 7" />
|
|
7
|
+
<path d="m7.5 4.27 9 5.15" />
|
|
8
|
+
</g>
|
|
9
|
+
</svg>
|
package/src/avatars/bee.ts
CHANGED
|
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
|
|
|
2
2
|
|
|
3
3
|
const art: AvatarArt = {
|
|
4
4
|
name: 'bee',
|
|
5
|
-
robot: true,
|
|
6
5
|
pixels: [
|
|
7
|
-
'.#....#.',
|
|
8
|
-
'##.##.##',
|
|
9
|
-
'#.####.#',
|
|
10
|
-
'.######.',
|
|
11
|
-
'.######.',
|
|
12
6
|
'........',
|
|
13
|
-
'
|
|
14
|
-
'
|
|
7
|
+
'...++##.',
|
|
8
|
+
'..++###.',
|
|
9
|
+
'...+#...',
|
|
10
|
+
'.#####..',
|
|
11
|
+
'#.+#+###',
|
|
12
|
+
'##+#+##.',
|
|
13
|
+
'.#####..'
|
|
15
14
|
]
|
|
16
15
|
};
|
|
17
16
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import ThemePicker from './ThemePicker.svelte';
|
|
11
11
|
import Layers from '@lucide/svelte/icons/layers';
|
|
12
12
|
import FolderMinus from '@lucide/svelte/icons/folder-minus';
|
|
13
|
+
import FolderCog from '@lucide/svelte/icons/folder-cog';
|
|
13
14
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
14
15
|
import Hammer from '@lucide/svelte/icons/hammer';
|
|
15
16
|
import KeyRound from '@lucide/svelte/icons/key-round';
|
|
@@ -42,14 +43,17 @@
|
|
|
42
43
|
import {
|
|
43
44
|
CLAUDE_PERMISSION_MODES,
|
|
44
45
|
CLAUDE_EFFORT_LEVELS,
|
|
46
|
+
CLAUDE_OUTPUT_STYLES,
|
|
45
47
|
type ClaudePermissionMode,
|
|
46
48
|
type ClaudeEffortLevel,
|
|
49
|
+
type ClaudeOutputStyle,
|
|
47
50
|
type InstanceMode
|
|
48
51
|
} from '../types.ts';
|
|
49
52
|
import Terminal from '@lucide/svelte/icons/terminal';
|
|
50
53
|
import LayoutTemplate from '@lucide/svelte/icons/layout-template';
|
|
51
54
|
import ShieldCheck from '@lucide/svelte/icons/shield-check';
|
|
52
55
|
import Gauge from '@lucide/svelte/icons/gauge';
|
|
56
|
+
import MessageSquare from '@lucide/svelte/icons/message-square';
|
|
53
57
|
import { installPopupBackTrap } from '../lib/popup-nav.ts';
|
|
54
58
|
|
|
55
59
|
/** Every settings form action fails with the same `{ error }` shape. */
|
|
@@ -60,11 +64,13 @@
|
|
|
60
64
|
defaultMode,
|
|
61
65
|
claudePermissionMode,
|
|
62
66
|
claudeEffortLevel,
|
|
67
|
+
claudeOutputStyle,
|
|
63
68
|
defaultImage,
|
|
64
69
|
builtinImage,
|
|
65
70
|
disableBuildCache,
|
|
66
71
|
copyIgnorePatterns,
|
|
67
72
|
builtinCopyIgnore,
|
|
73
|
+
claudeConfigDir,
|
|
68
74
|
gitIdentityEnabled,
|
|
69
75
|
gitIdentityName,
|
|
70
76
|
gitIdentityEmail,
|
|
@@ -100,11 +106,13 @@
|
|
|
100
106
|
defaultMode: InstanceMode;
|
|
101
107
|
claudePermissionMode: ClaudePermissionMode;
|
|
102
108
|
claudeEffortLevel: ClaudeEffortLevel;
|
|
109
|
+
claudeOutputStyle: ClaudeOutputStyle;
|
|
103
110
|
defaultImage: string;
|
|
104
111
|
builtinImage: string;
|
|
105
112
|
disableBuildCache: boolean;
|
|
106
113
|
copyIgnorePatterns: string;
|
|
107
114
|
builtinCopyIgnore: string;
|
|
115
|
+
claudeConfigDir: string;
|
|
108
116
|
gitIdentityEnabled: boolean;
|
|
109
117
|
gitIdentityName: string;
|
|
110
118
|
gitIdentityEmail: string;
|
|
@@ -292,6 +300,33 @@
|
|
|
292
300
|
effortFormEl?.requestSubmit();
|
|
293
301
|
}
|
|
294
302
|
|
|
303
|
+
// svelte-ignore state_referenced_locally
|
|
304
|
+
let outputStyleChoice = $state<ClaudeOutputStyle>(claudeOutputStyle);
|
|
305
|
+
let savingOutputStyle = $state(false);
|
|
306
|
+
let outputStyleError = $state<string | null>(null);
|
|
307
|
+
let outputStyleFormEl: HTMLFormElement | undefined;
|
|
308
|
+
|
|
309
|
+
const outputStyleOpts = saveOpts<{ style: ClaudeOutputStyle }>({
|
|
310
|
+
setSaving: (v) => (savingOutputStyle = v),
|
|
311
|
+
setError: (v) => (outputStyleError = v),
|
|
312
|
+
setMsg: () => {},
|
|
313
|
+
onSuccess: (data) => {
|
|
314
|
+
if (data?.style) outputStyleChoice = data.style;
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const OUTPUT_STYLE_LABELS: Record<ClaudeOutputStyle, string> = {
|
|
319
|
+
default: 'Default',
|
|
320
|
+
none: 'None',
|
|
321
|
+
Concise: 'Concise'
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
function chooseOutputStyle(next: ClaudeOutputStyle) {
|
|
325
|
+
if (next === outputStyleChoice) return;
|
|
326
|
+
flushSync(() => (outputStyleChoice = next));
|
|
327
|
+
outputStyleFormEl?.requestSubmit();
|
|
328
|
+
}
|
|
329
|
+
|
|
295
330
|
// svelte-ignore state_referenced_locally
|
|
296
331
|
let image = $state(defaultImage);
|
|
297
332
|
let savingImage = $state(false);
|
|
@@ -340,6 +375,29 @@
|
|
|
340
375
|
copyIgnoreFormEl?.requestSubmit();
|
|
341
376
|
}
|
|
342
377
|
|
|
378
|
+
// Blank means "use ~/.claude" — the host dir the Claude config injections read from.
|
|
379
|
+
// svelte-ignore state_referenced_locally
|
|
380
|
+
let claudeDir = $state(claudeConfigDir);
|
|
381
|
+
let savingClaudeDir = $state(false);
|
|
382
|
+
let claudeDirError = $state<string | null>(null);
|
|
383
|
+
let claudeDirSaved = $state(false);
|
|
384
|
+
let claudeDirFormEl: HTMLFormElement | undefined;
|
|
385
|
+
|
|
386
|
+
const claudeDirOpts = saveOpts<{ dir: string }>({
|
|
387
|
+
setSaving: (v) => (savingClaudeDir = v),
|
|
388
|
+
setError: (v) => (claudeDirError = v),
|
|
389
|
+
setMsg: (v) => (claudeDirSaved = !!v),
|
|
390
|
+
onSuccess: (data) => {
|
|
391
|
+
claudeDir = data?.dir ?? claudeDir;
|
|
392
|
+
claudeDirSaved = true;
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
function resetClaudeDir() {
|
|
397
|
+
flushSync(() => (claudeDir = ''));
|
|
398
|
+
claudeDirFormEl?.requestSubmit();
|
|
399
|
+
}
|
|
400
|
+
|
|
343
401
|
// svelte-ignore state_referenced_locally
|
|
344
402
|
let gitIdentity = $state(gitIdentityEnabled);
|
|
345
403
|
let savingGitToggle = $state(false);
|
|
@@ -973,6 +1031,48 @@
|
|
|
973
1031
|
{/if}
|
|
974
1032
|
</section>
|
|
975
1033
|
|
|
1034
|
+
<section class="card">
|
|
1035
|
+
<form
|
|
1036
|
+
class="row"
|
|
1037
|
+
method="POST"
|
|
1038
|
+
action="?/claudeOutputStyle"
|
|
1039
|
+
bind:this={outputStyleFormEl}
|
|
1040
|
+
{@attach enhance(outputStyleOpts)}
|
|
1041
|
+
>
|
|
1042
|
+
<div class="label">
|
|
1043
|
+
<MessageSquare size={20} />
|
|
1044
|
+
<div class="text">
|
|
1045
|
+
<div class="name">Claude output style</div>
|
|
1046
|
+
<div class="desc">
|
|
1047
|
+
The output style Claude Code starts new sessions in, written to
|
|
1048
|
+
<code>~/.claude/settings.json</code>. <strong>Default</strong> inherits your host
|
|
1049
|
+
<code>~/.claude/settings.json</code> (including any custom styles);
|
|
1050
|
+
<strong>None</strong>
|
|
1051
|
+
forces it off. Applies on create and rebuild, not to already-running instances.
|
|
1052
|
+
</div>
|
|
1053
|
+
</div>
|
|
1054
|
+
</div>
|
|
1055
|
+
<input type="hidden" name="style" value={outputStyleChoice} />
|
|
1056
|
+
<div class="mode-toggle" role="group" aria-label="Claude output style">
|
|
1057
|
+
{#each CLAUDE_OUTPUT_STYLES as option (option)}
|
|
1058
|
+
<button
|
|
1059
|
+
type="button"
|
|
1060
|
+
class="mode-btn"
|
|
1061
|
+
class:active={outputStyleChoice === option}
|
|
1062
|
+
aria-pressed={outputStyleChoice === option}
|
|
1063
|
+
disabled={savingOutputStyle}
|
|
1064
|
+
onclick={() => chooseOutputStyle(option)}
|
|
1065
|
+
>
|
|
1066
|
+
{OUTPUT_STYLE_LABELS[option]}
|
|
1067
|
+
</button>
|
|
1068
|
+
{/each}
|
|
1069
|
+
</div>
|
|
1070
|
+
</form>
|
|
1071
|
+
{#if outputStyleError}
|
|
1072
|
+
<div class="sub"><div class="msg error">{outputStyleError}</div></div>
|
|
1073
|
+
{/if}
|
|
1074
|
+
</section>
|
|
1075
|
+
|
|
976
1076
|
<section class="card">
|
|
977
1077
|
<form
|
|
978
1078
|
class="row image-row"
|
|
@@ -1082,6 +1182,54 @@
|
|
|
1082
1182
|
</form>
|
|
1083
1183
|
</section>
|
|
1084
1184
|
|
|
1185
|
+
<section class="card">
|
|
1186
|
+
<form
|
|
1187
|
+
class="row image-row"
|
|
1188
|
+
method="POST"
|
|
1189
|
+
action="?/claudeConfigDir"
|
|
1190
|
+
bind:this={claudeDirFormEl}
|
|
1191
|
+
{@attach enhance(claudeDirOpts)}
|
|
1192
|
+
>
|
|
1193
|
+
<div class="label">
|
|
1194
|
+
<FolderCog size={20} />
|
|
1195
|
+
<div class="text">
|
|
1196
|
+
<div class="name">Claude config directory</div>
|
|
1197
|
+
<div class="desc">
|
|
1198
|
+
Host directory the Claude Code injections read from — credentials, statusLine, output
|
|
1199
|
+
styles, and global skills / <code>CLAUDE.md</code>. Leave blank to use
|
|
1200
|
+
<code>~/.claude</code>. Takes effect for instances created from now on.
|
|
1201
|
+
</div>
|
|
1202
|
+
</div>
|
|
1203
|
+
</div>
|
|
1204
|
+
<div class="image-controls">
|
|
1205
|
+
<input
|
|
1206
|
+
type="text"
|
|
1207
|
+
name="dir"
|
|
1208
|
+
class="image-input"
|
|
1209
|
+
bind:value={claudeDir}
|
|
1210
|
+
spellcheck="false"
|
|
1211
|
+
autocapitalize="off"
|
|
1212
|
+
autocorrect="off"
|
|
1213
|
+
placeholder="~/.claude"
|
|
1214
|
+
/>
|
|
1215
|
+
<Button type="submit" disabled={savingClaudeDir}>Save</Button>
|
|
1216
|
+
<Button
|
|
1217
|
+
type="button"
|
|
1218
|
+
icon={RotateCcw}
|
|
1219
|
+
disabled={savingClaudeDir}
|
|
1220
|
+
onclick={resetClaudeDir}
|
|
1221
|
+
title="Reset to default (~/.claude)"
|
|
1222
|
+
aria-label="Reset to default Claude config directory"
|
|
1223
|
+
/>
|
|
1224
|
+
</div>
|
|
1225
|
+
{#if claudeDirError}
|
|
1226
|
+
<div class="msg error">{claudeDirError}</div>
|
|
1227
|
+
{:else if claudeDirSaved}
|
|
1228
|
+
<div class="msg ok">Saved.</div>
|
|
1229
|
+
{/if}
|
|
1230
|
+
</form>
|
|
1231
|
+
</section>
|
|
1232
|
+
|
|
1085
1233
|
<section class="card">
|
|
1086
1234
|
<form
|
|
1087
1235
|
class="row"
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
4
|
-
import { join } from 'node:path';
|
|
3
|
+
import { hostClaudeFile } from '../lib/host-claude.server.ts';
|
|
5
4
|
import { CLAUDE_CODE_TOKEN, CLAUDE_KEYCHAIN_SERVICE } from '../lib/config.server.ts';
|
|
6
5
|
import { getOption } from '../lib/db.server.ts';
|
|
7
6
|
import { checkPresence } from '../lib/exec.server.ts';
|
|
@@ -84,7 +83,7 @@ async function locateClaudeCredentials(): Promise<{ creds: string; source: strin
|
|
|
84
83
|
}
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
const file =
|
|
86
|
+
const file = hostClaudeFile('.credentials.json');
|
|
88
87
|
if (existsSync(file)) {
|
|
89
88
|
const raw = (await readFile(file, 'utf8')).trim();
|
|
90
89
|
if (isValid(raw)) return { creds: raw, source: '~/.claude/.credentials.json' };
|
|
@@ -8,20 +8,25 @@ import type { Injection } from '../lib/injections.server.ts';
|
|
|
8
8
|
* Node, else the standalone installer, which needs no Node at all.
|
|
9
9
|
*
|
|
10
10
|
* `$1` is the remote user (the injection passes it; the build-time feature falls back to the
|
|
11
|
-
* devcontainer CLI's `$_REMOTE_USER`)
|
|
12
|
-
*
|
|
11
|
+
* devcontainer CLI's `$_REMOTE_USER`). Claude Code 2.x's native binary — installed by npm's
|
|
12
|
+
* postinstall as much as by the standalone installer — lands under that user's `$HOME`, so both
|
|
13
|
+
* branches run as that user via `run_as`; running as root would strand the binary in `/root`.
|
|
14
|
+
* Success is judged by `claude --version` (as the user), not `command -v claude`, so a stranded
|
|
15
|
+
* binary the wrapper still resolves falls through to the standalone installer instead of passing.
|
|
13
16
|
*/
|
|
14
17
|
export const INSTALL_SCRIPT =
|
|
15
18
|
'if command -v claude >/dev/null 2>&1; then exit 0; fi\n' +
|
|
16
|
-
'
|
|
17
|
-
'
|
|
19
|
+
'u="${1:-${_REMOTE_USER:-root}}"\n' +
|
|
20
|
+
// Fall back to a home the remote user owns, never root's, so `su -m` can't strand the binary in /root.
|
|
21
|
+
'h="$(getent passwd "$u" 2>/dev/null | cut -d: -f6)"; [ -n "$h" ] || { [ "$u" = root ] && h=/root || h="/home/$u"; }\n' +
|
|
22
|
+
'run_as() { if [ "$(id -un)" = "$u" ]; then HOME="$h" sh -c "$1"; else HOME="$h" su -m "$u" -c "$1"; fi; }\n' +
|
|
23
|
+
"if command -v npm >/dev/null 2>&1 && run_as 'npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1'; then\n" +
|
|
24
|
+
' p="$(run_as \'npm prefix -g 2>/dev/null\')"\n' +
|
|
18
25
|
// npm's global bin is off PATH whenever the image sets its own NPM_CONFIG_PREFIX, so a bare
|
|
19
26
|
// `command -v` would miss an install that just succeeded — and so would the terminal launcher.
|
|
20
27
|
' [ -n "$p" ] && [ -x "$p/bin/claude" ] && ln -sf "$p/bin/claude" /usr/local/bin/claude 2>/dev/null\n' +
|
|
21
|
-
|
|
28
|
+
" if run_as 'claude --version >/dev/null 2>&1'; then exit 0; fi\n" +
|
|
22
29
|
'fi\n' +
|
|
23
|
-
'u="${1:-${_REMOTE_USER:-root}}"\n' +
|
|
24
|
-
'h="$(getent passwd "$u" 2>/dev/null | cut -d: -f6)"; [ -n "$h" ] || h="$HOME"\n' +
|
|
25
30
|
'f="$(mktemp)"\n' +
|
|
26
31
|
'if command -v curl >/dev/null 2>&1; then curl -fsSL https://claude.ai/install.sh -o "$f"\n' +
|
|
27
32
|
'elif command -v wget >/dev/null 2>&1; then wget -qO "$f" https://claude.ai/install.sh\n' +
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { posix } from 'node:path';
|
|
4
|
+
import { checkPresence } from '../lib/exec.server.ts';
|
|
5
|
+
import { writeContainerFileBytes } from '../lib/container-files.server.ts';
|
|
6
|
+
import { claudeConfigFile } from '../lib/claude-settings.server.ts';
|
|
7
|
+
import { hostClaudeFile } from '../lib/host-claude.server.ts';
|
|
8
|
+
import type { Injection } from '../lib/injections.server.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The env-var stdin carrier caps a single write near 128 KB (Linux `MAX_ARG_STRLEN`); base64
|
|
12
|
+
* inflates ~4/3, so a raw file over this is skipped rather than failing the whole injection.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_FILE_BYTES = 90_000;
|
|
15
|
+
|
|
16
|
+
interface HostFile {
|
|
17
|
+
/** Path relative to the host Claude dir, e.g. `CLAUDE.md` or `skills/foo/SKILL.md`. */
|
|
18
|
+
rel: string;
|
|
19
|
+
bytes: Buffer;
|
|
20
|
+
/** True when any execute bit is set on the host, so container scripts stay runnable. */
|
|
21
|
+
exec: boolean;
|
|
22
|
+
oversized: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Recursively lists every file under a host dir, as `skills/<subpath>` entries. */
|
|
26
|
+
async function walkSkills(dir: string, rel: string, out: HostFile[]): Promise<void> {
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
30
|
+
} catch {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const abs = posix.join(dir, entry.name);
|
|
35
|
+
const childRel = posix.join(rel, entry.name);
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
await walkSkills(abs, childRel, out);
|
|
38
|
+
} else if (entry.isFile()) {
|
|
39
|
+
const info = await stat(abs);
|
|
40
|
+
out.push({
|
|
41
|
+
rel: childRel,
|
|
42
|
+
bytes: info.size > MAX_FILE_BYTES ? Buffer.alloc(0) : await readFile(abs),
|
|
43
|
+
exec: (info.mode & 0o111) !== 0,
|
|
44
|
+
oversized: info.size > MAX_FILE_BYTES
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Cheap presence probe for the setup-UI chip — never reads file contents. */
|
|
51
|
+
export async function hasHostSkillFiles(): Promise<boolean> {
|
|
52
|
+
if (existsSync(hostClaudeFile('CLAUDE.md'))) return true;
|
|
53
|
+
try {
|
|
54
|
+
return (await readdir(hostClaudeFile('skills'))).length > 0;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The host global CLAUDE.md plus every file under the host skills dir. */
|
|
61
|
+
export async function collectHostSkillFiles(): Promise<HostFile[]> {
|
|
62
|
+
const files: HostFile[] = [];
|
|
63
|
+
const claudeMd = hostClaudeFile('CLAUDE.md');
|
|
64
|
+
if (existsSync(claudeMd)) {
|
|
65
|
+
const info = await stat(claudeMd);
|
|
66
|
+
if (info.isFile()) {
|
|
67
|
+
files.push({
|
|
68
|
+
rel: 'CLAUDE.md',
|
|
69
|
+
bytes: info.size > MAX_FILE_BYTES ? Buffer.alloc(0) : await readFile(claudeMd),
|
|
70
|
+
exec: false,
|
|
71
|
+
oversized: info.size > MAX_FILE_BYTES
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
await walkSkills(hostClaudeFile('skills'), 'skills', files);
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const claudeCodeSkills: Injection = {
|
|
80
|
+
id: 'claude-code-skills',
|
|
81
|
+
label: 'skills & CLAUDE.md',
|
|
82
|
+
|
|
83
|
+
auth: {
|
|
84
|
+
hint: 'add skills or a CLAUDE.md to your host ~/.claude',
|
|
85
|
+
async status() {
|
|
86
|
+
const available = await hasHostSkillFiles();
|
|
87
|
+
return { available, source: available ? '~/.claude' : null };
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async apply(target, log) {
|
|
92
|
+
const files = await collectHostSkillFiles();
|
|
93
|
+
if (!files.length) {
|
|
94
|
+
log('⚠ No global skills or CLAUDE.md found on host; skipped\n');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
log(`Injecting ${files.length} global skill/CLAUDE.md file(s)…\n`);
|
|
98
|
+
let failed = 0;
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
if (file.oversized) {
|
|
101
|
+
log(`⚠ Skipped ${file.rel} (larger than ${MAX_FILE_BYTES} bytes)\n`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const dest = claudeConfigFile(file.rel, file.exec ? '755' : '644');
|
|
105
|
+
const wrote = await writeContainerFileBytes(target, dest, file.bytes);
|
|
106
|
+
if (!wrote.ok) {
|
|
107
|
+
failed++;
|
|
108
|
+
log(`⚠ Failed to write ${file.rel}: ${wrote.error}\n`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
log(
|
|
112
|
+
failed
|
|
113
|
+
? `⚠ ${failed} skill file(s) failed to inject\n`
|
|
114
|
+
: '✓ Global skills & CLAUDE.md injected\n'
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async check(target) {
|
|
119
|
+
return checkPresence(
|
|
120
|
+
target,
|
|
121
|
+
'h=$(eval echo ~$(id -un)); d="${CLAUDE_CONFIG_DIR:-$h/.claude}"; ' +
|
|
122
|
+
'if [ -s "$d/CLAUDE.md" ] || [ -n "$(ls -A "$d/skills" 2>/dev/null)" ]; then echo 1; else echo 0; fi'
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
@@ -8,29 +8,63 @@ import type { Injection } from '../lib/injections.server.ts';
|
|
|
8
8
|
* was current when the layer was first baked. This re-checks at boot and only reinstalls when
|
|
9
9
|
* behind — no-op if Claude Code isn't installed (project-owned images own their tooling) or
|
|
10
10
|
* npm/registry is unreachable, and it echoes `updated <old> -> <new>` so `apply()` can log it.
|
|
11
|
+
*
|
|
12
|
+
* `$1` is the container's remote user. Claude Code 2.x ships a native binary that npm's
|
|
13
|
+
* postinstall drops into the *invoking* user's HOME, so the reinstall (and the `claude --version`
|
|
14
|
+
* probes) run as that user via `run_as` — a root reinstall would strand the binary in `/root`,
|
|
15
|
+
* exactly as the standalone installer in `claude-code-install.ts` would. Success is judged by a
|
|
16
|
+
* post-install `claude --version` (as the remote user), not npm's exit code, so a stranded or
|
|
17
|
+
* half-downloaded binary surfaces as a failure instead of a false `updated`.
|
|
11
18
|
*/
|
|
19
|
+
const RUN_AS_PRELUDE =
|
|
20
|
+
'u="${1:-root}"; ' +
|
|
21
|
+
// Fall back to a home the remote user actually owns, never root's — otherwise `su -m` below
|
|
22
|
+
// would strand the native binary in /root, the exact failure this exists to prevent.
|
|
23
|
+
'h="$(getent passwd "$u" 2>/dev/null | cut -d: -f6)"; [ -n "$h" ] || { [ "$u" = root ] && h=/root || h="/home/$u"; }; ' +
|
|
24
|
+
'run_as() { if [ "$(id -un)" = "$u" ]; then HOME="$h" sh -c "$1"; else HOME="$h" su -m "$u" -c "$1"; fi; }; ' +
|
|
25
|
+
"ver() { run_as 'claude --version 2>/dev/null' | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -n1; }; ";
|
|
26
|
+
|
|
27
|
+
const NPM_INSTALL_CMD = 'npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Prefer the remote user — correct HOME and ownership on the common image whose global prefix is
|
|
31
|
+
* chowned to that user. Fall back to a root install with HOME pointed at the user's home when the
|
|
32
|
+
* user can't write a root-owned global prefix (`su` would EACCES there). Success is decided by the
|
|
33
|
+
* post-install `ver()`, so neither branch's exit status matters.
|
|
34
|
+
*/
|
|
35
|
+
const REINSTALL =
|
|
36
|
+
`if run_as '${NPM_INSTALL_CMD}'; then :; ` +
|
|
37
|
+
`elif [ "$(id -un)" != "$u" ]; then HOME="$h" ${NPM_INSTALL_CMD} || true; fi; `;
|
|
38
|
+
|
|
12
39
|
export const UPDATE_SCRIPT =
|
|
13
40
|
'command -v claude >/dev/null 2>&1 || exit 0; ' +
|
|
14
41
|
'command -v npm >/dev/null 2>&1 || exit 0; ' +
|
|
15
|
-
|
|
42
|
+
RUN_AS_PRELUDE +
|
|
43
|
+
'installed="$(ver)"; ' +
|
|
16
44
|
'latest=$(npm view @anthropic-ai/claude-code version 2>/dev/null); ' +
|
|
17
45
|
'if [ -z "$latest" ]; then exit 0; fi; ' +
|
|
18
46
|
'if [ "$installed" = "$latest" ]; then echo "current $latest"; exit 0; fi; ' +
|
|
19
|
-
|
|
20
|
-
'
|
|
47
|
+
REINSTALL +
|
|
48
|
+
'now="$(ver)"; ' +
|
|
49
|
+
'if [ "$now" = "$latest" ]; then echo "updated ${installed:-none} -> $now"; exit 0; fi; ' +
|
|
50
|
+
'exit 1';
|
|
21
51
|
|
|
22
52
|
/**
|
|
23
53
|
* Same check with the latest version supplied by the host as `$0` (exec args, never interpolated
|
|
24
|
-
* into the script), skipping the in-container `npm view` registry round-trip.
|
|
54
|
+
* into the script), skipping the in-container `npm view` registry round-trip. `$1` is the remote
|
|
55
|
+
* user — see `UPDATE_SCRIPT` above for why the reinstall must run as that user.
|
|
25
56
|
*/
|
|
26
57
|
export const PINNED_UPDATE_SCRIPT =
|
|
27
58
|
'latest="$0"; ' +
|
|
28
59
|
'command -v claude >/dev/null 2>&1 || exit 0; ' +
|
|
29
60
|
'command -v npm >/dev/null 2>&1 || exit 0; ' +
|
|
30
|
-
|
|
61
|
+
RUN_AS_PRELUDE +
|
|
62
|
+
'installed="$(ver)"; ' +
|
|
31
63
|
'if [ "$installed" = "$latest" ]; then echo "current $latest"; exit 0; fi; ' +
|
|
32
|
-
|
|
33
|
-
'
|
|
64
|
+
REINSTALL +
|
|
65
|
+
'now="$(ver)"; ' +
|
|
66
|
+
'if [ "$now" = "$latest" ]; then echo "updated ${installed:-none} -> $now"; exit 0; fi; ' +
|
|
67
|
+
'exit 1';
|
|
34
68
|
|
|
35
69
|
/** Anything looser could smuggle shell metacharacters out of a corrupted DB row into a container. */
|
|
36
70
|
export const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
@@ -84,12 +118,13 @@ export const claudeCodeUpdate: Injection = {
|
|
|
84
118
|
async apply(target, log) {
|
|
85
119
|
log('Checking Claude Code is up to date…\n');
|
|
86
120
|
const latest = await resolveLatestVersion();
|
|
87
|
-
//
|
|
121
|
+
// Runs as root (remoteUser omitted) so the script can `su -m` into the remote user for the
|
|
122
|
+
// reinstall, keeping Claude Code 2.x's native binary in that user's HOME (see UPDATE_SCRIPT).
|
|
88
123
|
const res = await execInContainer(
|
|
89
124
|
{ containerId: target.containerId },
|
|
90
125
|
latest
|
|
91
|
-
? { script: PINNED_UPDATE_SCRIPT, args: [latest], capture: true }
|
|
92
|
-
: { script: UPDATE_SCRIPT, capture: true }
|
|
126
|
+
? { script: PINNED_UPDATE_SCRIPT, args: [latest, target.remoteUser ?? ''], capture: true }
|
|
127
|
+
: { script: UPDATE_SCRIPT, args: ['claude-update', target.remoteUser ?? ''], capture: true }
|
|
93
128
|
);
|
|
94
129
|
if (!res.ok) {
|
|
95
130
|
log(`⚠ Claude Code update check failed: ${res.error} — keeping the installed version\n`);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getOption } from '../lib/db.server.ts';
|
|
4
|
+
import { writeContainerFile } from '../lib/container-files.server.ts';
|
|
5
|
+
import {
|
|
6
|
+
claudeConfigFile,
|
|
7
|
+
mergeClaudeSettings,
|
|
8
|
+
readClaudeSettings
|
|
9
|
+
} from '../lib/claude-settings.server.ts';
|
|
10
|
+
import { hostClaudeFile, readHostClaudeSettings } from '../lib/host-claude.server.ts';
|
|
11
|
+
import { normalizeOutputStyle } from '../types.ts';
|
|
12
|
+
import type { ClaudeOutputStyle } from '../types.ts';
|
|
13
|
+
import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
|
|
14
|
+
|
|
15
|
+
/** Also read by the settings-page `serverProps` to seed the UI. */
|
|
16
|
+
export function getClaudeOutputStyle(): ClaudeOutputStyle {
|
|
17
|
+
return normalizeOutputStyle(getOption('claude_output_style'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The `outputStyle` actually applied: the setting itself, the host's value when inheriting, or null. */
|
|
21
|
+
export async function resolveEffectiveOutputStyle(): Promise<string | null> {
|
|
22
|
+
const setting = getClaudeOutputStyle();
|
|
23
|
+
if (setting === 'none') return null;
|
|
24
|
+
if (setting !== 'default') return setting;
|
|
25
|
+
const style = (await readHostClaudeSettings())?.outputStyle;
|
|
26
|
+
return typeof style === 'string' && style.trim() ? style.trim() : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The host's custom output-style definitions, so a non-built-in name resolves inside the container. */
|
|
30
|
+
export async function readHostOutputStyleFiles(): Promise<{ name: string; content: string }[]> {
|
|
31
|
+
const dir = hostClaudeFile('output-styles');
|
|
32
|
+
let names: string[];
|
|
33
|
+
try {
|
|
34
|
+
names = (await readdir(dir)).filter((n) => n.endsWith('.md'));
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const files = await Promise.all(
|
|
39
|
+
names.map(async (name) => {
|
|
40
|
+
try {
|
|
41
|
+
return { name, content: await readFile(join(dir, name), 'utf8') };
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
return files.filter((f): f is { name: string; content: string } => f !== null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Writes Claude Code's `outputStyle` default into the container, mirroring host custom-style files. */
|
|
51
|
+
export const claudeOutputStyle: Injection = {
|
|
52
|
+
id: 'claude-output-style',
|
|
53
|
+
label: 'output style',
|
|
54
|
+
|
|
55
|
+
async apply(target: ContainerTarget, log) {
|
|
56
|
+
const style = await resolveEffectiveOutputStyle();
|
|
57
|
+
if (!style) {
|
|
58
|
+
log('No Claude output style to apply; skipped\n');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const file of await readHostOutputStyleFiles()) {
|
|
62
|
+
await writeContainerFile(
|
|
63
|
+
target,
|
|
64
|
+
claudeConfigFile(`output-styles/${file.name}`, '644'),
|
|
65
|
+
file.content
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
log(`Setting Claude output style (${style})…\n`);
|
|
69
|
+
const result = await mergeClaudeSettings(target, { outputStyle: style });
|
|
70
|
+
log(
|
|
71
|
+
result.ok
|
|
72
|
+
? '✓ Claude output style configured in container\n'
|
|
73
|
+
: `⚠ Claude output style injection failed: ${result.error}\n`
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async check(target) {
|
|
78
|
+
const expected = await resolveEffectiveOutputStyle();
|
|
79
|
+
const settings = await readClaudeSettings(target);
|
|
80
|
+
const actual = settings?.outputStyle;
|
|
81
|
+
return expected === null ? actual == null : actual === expected;
|
|
82
|
+
}
|
|
83
|
+
};
|