codebay 0.13.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebay",
3
- "version": "0.13.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>
@@ -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';
@@ -69,6 +70,7 @@
69
70
  disableBuildCache,
70
71
  copyIgnorePatterns,
71
72
  builtinCopyIgnore,
73
+ claudeConfigDir,
72
74
  gitIdentityEnabled,
73
75
  gitIdentityName,
74
76
  gitIdentityEmail,
@@ -110,6 +112,7 @@
110
112
  disableBuildCache: boolean;
111
113
  copyIgnorePatterns: string;
112
114
  builtinCopyIgnore: string;
115
+ claudeConfigDir: string;
113
116
  gitIdentityEnabled: boolean;
114
117
  gitIdentityName: string;
115
118
  gitIdentityEmail: string;
@@ -372,6 +375,29 @@
372
375
  copyIgnoreFormEl?.requestSubmit();
373
376
  }
374
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
+
375
401
  // svelte-ignore state_referenced_locally
376
402
  let gitIdentity = $state(gitIdentityEnabled);
377
403
  let savingGitToggle = $state(false);
@@ -1156,6 +1182,54 @@
1156
1182
  </form>
1157
1183
  </section>
1158
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
+
1159
1233
  <section class="card">
1160
1234
  <form
1161
1235
  class="row"
@@ -1,7 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
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 = join(homedir(), '.claude', '.credentials.json');
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' };
@@ -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
+ };
@@ -1,5 +1,4 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
4
3
  import { getOption } from '../lib/db.server.ts';
5
4
  import { writeContainerFile } from '../lib/container-files.server.ts';
@@ -8,7 +7,7 @@ import {
8
7
  mergeClaudeSettings,
9
8
  readClaudeSettings
10
9
  } from '../lib/claude-settings.server.ts';
11
- import { readHostClaudeSettings } from './claude-statusline.ts';
10
+ import { hostClaudeFile, readHostClaudeSettings } from '../lib/host-claude.server.ts';
12
11
  import { normalizeOutputStyle } from '../types.ts';
13
12
  import type { ClaudeOutputStyle } from '../types.ts';
14
13
  import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
@@ -29,7 +28,7 @@ export async function resolveEffectiveOutputStyle(): Promise<string | null> {
29
28
 
30
29
  /** The host's custom output-style definitions, so a non-built-in name resolves inside the container. */
31
30
  export async function readHostOutputStyleFiles(): Promise<{ name: string; content: string }[]> {
32
- const dir = join(homedir(), '.claude', 'output-styles');
31
+ const dir = hostClaudeFile('output-styles');
33
32
  let names: string[];
34
33
  try {
35
34
  names = (await readdir(dir)).filter((n) => n.endsWith('.md'));
@@ -1,15 +1,17 @@
1
- import { existsSync, statSync } from 'node:fs';
1
+ import { statSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
5
3
  import { writeContainerFile } from '../lib/container-files.server.ts';
6
4
  import {
7
5
  claudeConfigFile,
8
6
  mergeClaudeSettings,
9
7
  readClaudeSettings
10
8
  } from '../lib/claude-settings.server.ts';
9
+ import { expandTilde, readHostClaudeSettings } from '../lib/host-claude.server.ts';
11
10
  import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
12
11
 
12
+ // Re-exported so the modules and tests that historically imported these from here keep working.
13
+ export { expandTilde, readHostClaudeSettings };
14
+
13
15
  /** Left unexpanded — Claude Code invokes this long after the injection has finished. */
14
16
  const CONTAINER_SCRIPT_PATH = '${CLAUDE_CONFIG_DIR:-$HOME/.claude}/statusline.sh';
15
17
 
@@ -19,19 +21,6 @@ interface StatusLineConfig {
19
21
  script?: string;
20
22
  }
21
23
 
22
- export async function readHostClaudeSettings(): Promise<Record<string, unknown> | null> {
23
- const file = join(homedir(), '.claude', 'settings.json');
24
- if (!existsSync(file)) return null;
25
- try {
26
- return JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
27
- } catch {
28
- return null;
29
- }
30
- }
31
-
32
- export const expandTilde = (p: string): string =>
33
- p.startsWith('~/') ? join(homedir(), p.slice(2)) : p;
34
-
35
24
  /** Only a real file counts — else jq's `//` operator and a bare `/` resolve to the root dir. */
36
25
  const isExistingFile = (p: string): boolean => {
37
26
  try {
@@ -54,6 +54,15 @@ export function writeFileScript(file: ContainerFile): string {
54
54
  );
55
55
  }
56
56
 
57
+ /** Like `writeFileScript`, but the stdin payload is base64 so binary bytes survive the env-var carrier. */
58
+ export function writeBase64FileScript(file: ContainerFile): string {
59
+ const mode = file.mode ?? '644';
60
+ return (
61
+ `set -e; ${HOME_PRELUDE}f="${filePath(file)}"; mkdir -p "$(dirname "$f")"; ` +
62
+ `printf '%s' "$CODEBAY_STDIN" | base64 -d > "$f"; chmod ${mode} "$f"`
63
+ );
64
+ }
65
+
57
66
  /** Single-quotes a value for a sourced shell file so whatever it contains stays literal. */
58
67
  export const shellSingleQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
59
68
 
@@ -98,6 +107,19 @@ export async function writeContainerFile(
98
107
  return res.ok ? { ok: true } : { ok: false, error: res.error };
99
108
  }
100
109
 
110
+ /** Writes raw `bytes` to a container file, ferrying them as base64 so binary content stays intact. */
111
+ export async function writeContainerFileBytes(
112
+ target: ExecTarget,
113
+ file: ContainerFile,
114
+ bytes: Buffer
115
+ ): Promise<{ ok: boolean; error?: string }> {
116
+ const res = await execInContainer(target, {
117
+ script: writeBase64FileScript(file),
118
+ stdin: bytes.toString('base64')
119
+ });
120
+ return res.ok ? { ok: true } : { ok: false, error: res.error };
121
+ }
122
+
101
123
  /** Appends each line to every file it's missing from — the shared idempotent rc/conf write. */
102
124
  export async function appendLinesIfAbsent(
103
125
  target: ExecTarget,
@@ -0,0 +1,31 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { getOption } from './db.server.ts';
6
+
7
+ /** Expands a leading `~` to the host home; other paths pass through untouched. */
8
+ export const expandTilde = (p: string): string =>
9
+ p === '~' ? homedir() : p.startsWith('~/') ? join(homedir(), p.slice(2)) : p;
10
+
11
+ /**
12
+ * Host directory the Claude config injections read from: the `claude_config_dir` setting when set,
13
+ * else `~/.claude`. One resolver so credentials, statusline, output styles, and skills never diverge.
14
+ */
15
+ export function hostClaudeDir(): string {
16
+ const custom = getOption('claude_config_dir')?.trim();
17
+ return custom ? expandTilde(custom) : join(homedir(), '.claude');
18
+ }
19
+
20
+ /** A path under the configured host Claude dir, e.g. `hostClaudeFile('CLAUDE.md')`. */
21
+ export const hostClaudeFile = (...parts: string[]): string => join(hostClaudeDir(), ...parts);
22
+
23
+ export async function readHostClaudeSettings(): Promise<Record<string, unknown> | null> {
24
+ const file = hostClaudeFile('settings.json');
25
+ if (!existsSync(file)) return null;
26
+ try {
27
+ return JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
@@ -7,6 +7,7 @@ import { ttyd } from '../container-injections/ttyd.ts';
7
7
  import { gitIdentity } from '../container-injections/git-identity.ts';
8
8
  import { claudeCodeCredentials } from '../container-injections/claude-code-credentials.ts';
9
9
  import { claudeCodeCustom } from '../container-injections/claude-code-custom.ts';
10
+ import { claudeCodeSkills } from '../container-injections/claude-code-skills.ts';
10
11
  import { claudeCodeIdeExtension } from '../container-injections/claude-code-ide-extension.ts';
11
12
  import { codeServerDark } from '../container-injections/code-server-dark.ts';
12
13
  import { claudeCodeInstall } from '../container-injections/claude-code-install.ts';
@@ -67,6 +68,7 @@ function buildStages(claudeInjection: Injection): Injection[][] {
67
68
  tmux,
68
69
  claudeCodeInstall,
69
70
  claudeInjection,
71
+ claudeCodeSkills,
70
72
  claudeCodeIdeExtension,
71
73
  claudeEffortLevel
72
74
  ],
@@ -21,6 +21,7 @@
21
21
  disableBuildCache,
22
22
  copyIgnorePatterns,
23
23
  builtinCopyIgnore,
24
+ claudeConfigDir,
24
25
  gitIdentityEnabled,
25
26
  gitIdentityName,
26
27
  gitIdentityEmail,
@@ -62,6 +63,7 @@
62
63
  disableBuildCache: boolean;
63
64
  copyIgnorePatterns: string;
64
65
  builtinCopyIgnore: string;
66
+ claudeConfigDir: string;
65
67
  gitIdentityEnabled: boolean;
66
68
  gitIdentityName: string;
67
69
  gitIdentityEmail: string;
@@ -106,6 +108,7 @@
106
108
  {disableBuildCache}
107
109
  {copyIgnorePatterns}
108
110
  {builtinCopyIgnore}
111
+ {claudeConfigDir}
109
112
  {gitIdentityEnabled}
110
113
  {gitIdentityName}
111
114
  {gitIdentityEmail}
package/src/routes.ts CHANGED
@@ -214,6 +214,8 @@ export const routes: Record<string, MochiRouteValue> = {
214
214
  // An explicit empty string (as opposed to unset) means "copy everything".
215
215
  copyIgnorePatterns: getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE,
216
216
  builtinCopyIgnore: DEFAULT_COPY_IGNORE,
217
+ // Blank means "use ~/.claude"; the host dir credentials/skills/statusLine read from.
218
+ claudeConfigDir: getOption('claude_config_dir') ?? '',
217
219
  // Not secrets, so the actual values (not just a "set" flag) go to the client.
218
220
  // Blank means "no override" — fall back to the host's git config.
219
221
  gitIdentityEnabled: gitIdentityEnabled(),
@@ -334,6 +336,13 @@ export const routes: Record<string, MochiRouteValue> = {
334
336
  return success({ patterns });
335
337
  },
336
338
 
339
+ // The host directory Claude config injections read from; blank falls back to ~/.claude.
340
+ claudeConfigDir: ({ formData }) => {
341
+ const dir = str(formData, 'dir').trim();
342
+ setOption('claude_config_dir', dir);
343
+ return success({ dir });
344
+ },
345
+
337
346
  gitIdentityToggle: ({ formData }) => {
338
347
  const enabled = onChecked(formData, 'enabled');
339
348
  setOption('git_identity_enabled', enabled ? '1' : '0');
package/src/shell.html CHANGED
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
7
  <title>Codebay</title>
7
8
  <style>
8
9
  /* Each themed variable is defined once as light-dark(light, dark). With no