codebay 0.5.0 → 0.7.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.
Files changed (114) hide show
  1. package/.mochi/manifest.json +286 -227
  2. package/.mochi/svelte-client/HydratableIsland-qyjx1d98.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-fkna7bj2.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-89wtkb86.js +13 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-eamnbyqk.js +1 -0
  6. package/.mochi/svelte-client/{_hydrate-SettingsView_8hmarwmf8onq-r9vvc7rx.js → _hydrate-SettingsView_8hmarwmf8onq-kdra4mcb.js} +3 -3
  7. package/.mochi/svelte-client/chunk-330mrspw.js +2 -0
  8. package/.mochi/svelte-client/{chunk-dz1019cy.js → chunk-4jgkak13.js} +1 -1
  9. package/.mochi/svelte-client/chunk-fqg2qzj8.js +2 -0
  10. package/.mochi/svelte-client/chunk-k5m4kt20.js +2 -0
  11. package/.mochi/svelte-client/{chunk-nmf4dq86.js → chunk-qj7zy2d9.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-ssa0ad2f.js → chunk-rpdphch7.js} +1 -1
  13. package/.mochi/svelte-compile/{App-9qjy14bc.server.js → App-6rcf0d74.server.js} +113 -46
  14. package/.mochi/svelte-compile/{Avatars-sq8mjn6k.server.js → Avatars-pzyz0tv3.server.js} +75 -15
  15. package/.mochi/svelte-compile/{Instance-13j59daw.server.js → Instance-49ksrp11.server.js} +15 -3
  16. package/.mochi/svelte-compile/{Settings-p0s2awg1.server.js → Settings-sz5d6qj7.server.js} +3 -3
  17. package/.mochi/svelte-compile/{chunk-dfe2km6a.js → chunk-q8rbesaw.js} +324 -123
  18. package/.mochi/svelte-css/Avatar-3pi30db6ozvf0.css +1 -0
  19. package/.mochi/svelte-css/AvatarEditor-2ewwtejaml4e2.css +1 -0
  20. package/.mochi/svelte-css/{AvatarsView-3hq37u4dgtljn.css → AvatarsView-2ughbzc26luyb.css} +1 -1
  21. package/.mochi/svelte-css/InstanceCard-3vnltxnbxkvay.css +1 -0
  22. package/.mochi/svelte-css/InstanceFilter-3q6x0jt0hcgpz.css +1 -0
  23. package/.mochi/svelte-css/{InstanceView-wq85bxtusont.css → InstanceView-3vzow3ttxzlh3.css} +1 -1
  24. package/README.md +2 -0
  25. package/package.json +2 -2
  26. package/src/avatars/anchor.ts +1 -0
  27. package/src/avatars/ant-head.ts +17 -0
  28. package/src/avatars/apple.ts +17 -0
  29. package/src/avatars/bear.ts +1 -0
  30. package/src/avatars/bee.ts +1 -0
  31. package/src/avatars/bodybuilder.ts +17 -0
  32. package/src/avatars/cat.ts +1 -0
  33. package/src/avatars/cherry.ts +1 -0
  34. package/src/avatars/crab.ts +1 -0
  35. package/src/avatars/crown.ts +1 -0
  36. package/src/avatars/diamond.ts +1 -0
  37. package/src/avatars/dog.ts +1 -0
  38. package/src/avatars/editor.ts +17 -0
  39. package/src/avatars/fish.ts +1 -0
  40. package/src/avatars/flower.ts +1 -0
  41. package/src/avatars/fox.ts +1 -0
  42. package/src/avatars/frog.ts +1 -0
  43. package/src/avatars/ghost.ts +1 -0
  44. package/src/avatars/heart.ts +8 -8
  45. package/src/avatars/horse.ts +17 -0
  46. package/src/avatars/index.ts +20 -0
  47. package/src/avatars/invader.ts +1 -0
  48. package/src/avatars/key.ts +1 -0
  49. package/src/avatars/lightning.ts +1 -0
  50. package/src/avatars/mushroom.ts +1 -0
  51. package/src/avatars/octopus.ts +1 -0
  52. package/src/avatars/owl.ts +1 -0
  53. package/src/avatars/penguin.ts +1 -0
  54. package/src/avatars/planet.ts +1 -0
  55. package/src/avatars/praying-mantis.ts +17 -0
  56. package/src/avatars/rabbit.ts +1 -0
  57. package/src/avatars/robot.ts +1 -0
  58. package/src/avatars/rocket.ts +1 -0
  59. package/src/avatars/serialize.ts +4 -3
  60. package/src/avatars/skull.ts +6 -6
  61. package/src/avatars/snail.ts +1 -0
  62. package/src/avatars/snowflake.ts +17 -0
  63. package/src/avatars/star.ts +1 -0
  64. package/src/avatars/strawberry.ts +17 -0
  65. package/src/avatars/target.ts +1 -0
  66. package/src/avatars/tree.ts +1 -0
  67. package/src/avatars/types.ts +6 -3
  68. package/src/avatars/vangogh-almond-blossoms.ts +17 -0
  69. package/src/avatars/wasp.ts +17 -0
  70. package/src/avatars/whale.ts +1 -0
  71. package/src/avatars/windmill.ts +17 -0
  72. package/src/components/AppShell.svelte +13 -0
  73. package/src/components/Avatar.svelte +4 -1
  74. package/src/components/AvatarEditor.svelte +83 -5
  75. package/src/components/AvatarsView.svelte +14 -1
  76. package/src/components/DashboardView.svelte +39 -4
  77. package/src/components/InstanceCard.svelte +55 -6
  78. package/src/components/InstanceFilter.svelte +68 -0
  79. package/src/components/InstanceView.svelte +41 -8
  80. package/src/components/SettingsView.svelte +2 -2
  81. package/src/components/TopBar.svelte +9 -1
  82. package/src/container-injections/attention-hooks.ts +19 -38
  83. package/src/container-injections/claude-aliases.ts +7 -23
  84. package/src/container-injections/claude-code-credentials.ts +12 -8
  85. package/src/container-injections/claude-code-custom.ts +35 -48
  86. package/src/container-injections/claude-code-ide-extension.ts +39 -0
  87. package/src/container-injections/claude-code-models.ts +8 -24
  88. package/src/container-injections/claude-model.ts +5 -28
  89. package/src/container-injections/claude-no-coauthor.ts +5 -17
  90. package/src/container-injections/claude-skip-permissions.ts +7 -17
  91. package/src/container-injections/claude-statusline.ts +16 -18
  92. package/src/container-injections/claude-trust.ts +73 -0
  93. package/src/container-injections/host-env-vars.ts +13 -24
  94. package/src/container-injections/tmux.ts +2 -11
  95. package/src/index.ts +2 -0
  96. package/src/lib/claude-settings.server.ts +35 -0
  97. package/src/lib/container-files.server.ts +219 -0
  98. package/src/lib/devcontainer.server.ts +38 -1
  99. package/src/lib/exec.server.ts +2 -7
  100. package/src/lib/injections.server.ts +4 -0
  101. package/src/lib/instances.server.ts +14 -0
  102. package/src/lib/theme.server.ts +7 -3
  103. package/src/shell.html +2 -0
  104. package/src/types.ts +3 -0
  105. package/.mochi/svelte-client/HydratableIsland-79z9xq4b.js +0 -1
  106. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-f5rzyjm8.js +0 -2
  107. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-fxyd5qx4.js +0 -13
  108. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-nv31ptdp.js +0 -1
  109. package/.mochi/svelte-client/chunk-6eexkr6p.js +0 -2
  110. package/.mochi/svelte-client/chunk-7znwaf7t.js +0 -2
  111. package/.mochi/svelte-client/chunk-zmmb1c4b.js +0 -2
  112. package/.mochi/svelte-css/Avatar-24th63awm9mna.css +0 -1
  113. package/.mochi/svelte-css/AvatarEditor-1ryrs1md5g36y.css +0 -1
  114. package/.mochi/svelte-css/InstanceCard-16f77aysm7lgp.css +0 -1
@@ -11,6 +11,7 @@
11
11
  import { enhance } from 'mochi-framework';
12
12
  import type { MochiEnhanceOptions } from 'mochi-framework';
13
13
  import { onBackLinkClick, installPopupBackTrap } from '../lib/popup-nav.ts';
14
+ import { tick } from 'svelte';
14
15
 
15
16
  let { id, injectionChecks = 0 }: { id: string; injectionChecks?: number } = $props();
16
17
 
@@ -18,11 +19,29 @@
18
19
  let health = $state<InstanceHealth | null>(null);
19
20
  let lastFetchedAt = $state<number | null>(null);
20
21
  let logs = $state('');
22
+ let logEl = $state<HTMLDivElement | null>(null);
23
+ let following = $state(true);
21
24
 
22
- // Runs after the DOM updates, so scrollHeight is fresh without a tick().
23
- function autoscroll(node: HTMLDivElement) {
24
- void logs; // the dependency this effect exists for
25
- node.scrollTop = node.scrollHeight;
25
+ // Pin to the newest output whenever logs grow — but only while following.
26
+ // tick() lets the <pre> text node grow before we scroll, so we reach the true bottom.
27
+ $effect(() => {
28
+ if (!following || logs.length === 0) return; // reading logs.length tracks appended chunks
29
+ tick().then(() => {
30
+ if (following) logEl?.scrollTo(0, logEl.scrollHeight);
31
+ });
32
+ });
33
+
34
+ // A user scrolling up pauses following; programmatic scroll-to-bottom keeps the
35
+ // gap ~0, so it never trips this — only re-clicking "Follow log" resumes.
36
+ function onLogScroll() {
37
+ if (!logEl) return;
38
+ const gap = logEl.scrollHeight - logEl.clientHeight - logEl.scrollTop;
39
+ if (gap > 40) following = false;
40
+ }
41
+
42
+ function toggleFollow() {
43
+ following = !following;
44
+ if (following) logEl?.scrollTo(0, logEl.scrollHeight);
26
45
  }
27
46
 
28
47
  $effect(() => installPopupBackTrap());
@@ -234,11 +253,16 @@
234
253
  <div class="logwrap panel">
235
254
  <div class="log-bar panel-bar">
236
255
  <span>Boot log</span>
237
- <button class="copy" onclick={copyLogs} disabled={!logs}>
238
- {copied ? 'Copied!' : 'Copy logs'}
239
- </button>
256
+ <div class="log-actions">
257
+ <button class="copy" class:active={following} onclick={toggleFollow}>
258
+ {following ? 'Following' : 'Follow log'}
259
+ </button>
260
+ <button class="copy" onclick={copyLogs} disabled={!logs}>
261
+ {copied ? 'Copied!' : 'Copy logs'}
262
+ </button>
263
+ </div>
240
264
  </div>
241
- <div class="logs" {@attach autoscroll}>
265
+ <div class="logs" bind:this={logEl} onscroll={onLogScroll}>
242
266
  <pre>{logs || 'Waiting for output…'}<span class="caret"></span></pre>
243
267
  </div>
244
268
  {#if instance?.status === 'error' && instance.error}
@@ -504,6 +528,11 @@
504
528
  align-items: center;
505
529
  justify-content: space-between;
506
530
  }
531
+ .log-actions {
532
+ display: flex;
533
+ gap: 6px;
534
+ align-items: center;
535
+ }
507
536
  .copy {
508
537
  font-family: var(--font-mono);
509
538
  font-weight: 600;
@@ -520,6 +549,10 @@
520
549
  background: transparent;
521
550
  color: var(--bg);
522
551
  }
552
+ .copy.active {
553
+ background: transparent;
554
+ color: var(--bg);
555
+ }
523
556
  .copy:disabled {
524
557
  opacity: 0.5;
525
558
  cursor: not-allowed;
@@ -878,8 +878,8 @@
878
878
  <div class="text">
879
879
  <div class="name">Rebuild running containers (no cache)</div>
880
880
  <div class="desc">
881
- Re-run <code>devcontainer up --build-no-cache</code> for every currently-running instance.
882
- In-container edits are kept; stopped instances are left alone.
881
+ Rebuild every currently-running instance from scratch, without using any cached image
882
+ layers. In-container edits are kept; stopped instances are left alone.
883
883
  </div>
884
884
  {#if rebuildError}
885
885
  <div class="msg error">{rebuildError}</div>
@@ -2,9 +2,10 @@
2
2
  import Plus from '@lucide/svelte/icons/plus';
3
3
  import Component from '@lucide/svelte/icons/component';
4
4
  import { isDev } from 'mochi-framework';
5
- import type { AuthProvider } from '../types.ts';
5
+ import type { AuthProvider, InstanceFilter } from '../types.ts';
6
6
  import type { AvatarArt } from '../avatars/index.ts';
7
7
  import Brand from './Brand.svelte';
8
+ import InstanceFilterControl from './InstanceFilter.svelte';
8
9
  import SettingsCog from './SettingsCog.svelte';
9
10
  import CredMenu from './CredMenu.svelte';
10
11
  import Button from './Button.svelte';
@@ -16,6 +17,8 @@
16
17
  canDelete = false,
17
18
  ready = true,
18
19
  creating = false,
20
+ filter,
21
+ onFilter,
19
22
  onNew,
20
23
  onDeleteAll
21
24
  }: {
@@ -24,6 +27,8 @@
24
27
  canDelete?: boolean;
25
28
  ready?: boolean;
26
29
  creating?: boolean;
30
+ filter?: InstanceFilter;
31
+ onFilter?: (v: InstanceFilter) => void;
27
32
  onNew?: () => void;
28
33
  onDeleteAll?: () => void;
29
34
  } = $props();
@@ -35,6 +40,9 @@
35
40
  {#if isDev}
36
41
  <Button variant="default" size="sm" icon={Component} href="/debug">Debug</Button>
37
42
  {/if}
43
+ {#if filter && onFilter}
44
+ <InstanceFilterControl value={filter} onchange={onFilter} />
45
+ {/if}
38
46
  <SettingsCog />
39
47
  <CredMenu {auth} />
40
48
  <Button variant="danger" size="sm" onclick={onDeleteAll} disabled={!canDelete}>
@@ -1,5 +1,10 @@
1
1
  import { PORT } from '../lib/config.server.ts';
2
- import { checkPresence, execInContainer } from '../lib/exec.server.ts';
2
+ import { writeContainerFile } from '../lib/container-files.server.ts';
3
+ import {
4
+ claudeConfigFile,
5
+ mergeClaudeSettings,
6
+ readClaudeSettings
7
+ } from '../lib/claude-settings.server.ts';
3
8
  import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
4
9
 
5
10
  function bridgeUrl(): string {
@@ -40,50 +45,32 @@ function hookFor(id: string, state: 'done' | 'waiting' | 'busy') {
40
45
  }
41
46
 
42
47
  /** Carries no token — that lives in the mode-600 header file `writeBridgeHeader` stages. */
43
- export function attentionHookSettings(id: string): string {
44
- const settings = {
48
+ export function attentionHookSettings(id: string): Record<string, unknown> {
49
+ return {
45
50
  hooks: {
46
51
  Stop: hookFor(id, 'done'),
47
52
  Notification: hookFor(id, 'waiting'),
48
53
  UserPromptSubmit: hookFor(id, 'busy')
49
54
  }
50
55
  };
51
- return JSON.stringify(settings, null, 2);
52
56
  }
53
57
 
54
58
  /** Stored as a whole header line so curl can consume the file directly with `-H @`. */
55
- async function writeBridgeHeader(
59
+ function writeBridgeHeader(
56
60
  target: ContainerTarget,
57
61
  token: string
58
62
  ): Promise<{ ok: boolean; error?: string }> {
59
- const script =
60
- 'h=$(eval echo ~$(id -un)); d="${CLAUDE_CONFIG_DIR:-$h/.claude}"; mkdir -p "$d"; ' +
61
- 'f="$d/.bridge-header"; printf \'X-Bridge-Token: %s\\n\' "$CODEBAY_STDIN" > "$f"; chmod 600 "$f"';
62
- const res = await execInContainer(target, { script, stdin: token });
63
- return res.ok ? { ok: true } : { ok: false, error: res.error };
64
- }
65
-
66
- /** Merging rather than overwriting is what lets the settings.json injections compose in any order. */
67
- export function mergeClaudeSettingsScript(): string {
68
- return (
69
- 'h=$(eval echo ~$(id -un)); d="${CLAUDE_CONFIG_DIR:-$h/.claude}"; mkdir -p "$d"; ' +
70
- 'f="$d/settings.json"; new="$CODEBAY_STDIN"; ' +
71
- 'if command -v jq >/dev/null 2>&1 && [ -s "$f" ] && ' +
72
- 'merged=$(printf \'%s\' "$new" | jq -s \'.[0] * .[1]\' "$f" - 2>/dev/null); then ' +
73
- 'printf \'%s\' "$merged" > "$f"; else printf \'%s\' "$new" > "$f"; fi; ' +
74
- 'chmod 644 "$f"'
63
+ return writeContainerFile(
64
+ target,
65
+ claudeConfigFile('.bridge-header', '600'),
66
+ `X-Bridge-Token: ${token}\n`
75
67
  );
76
68
  }
77
69
 
78
- async function injectClaudeHooks(
79
- target: ContainerTarget,
80
- settingsJson: string
81
- ): Promise<{ ok: boolean; error?: string }> {
82
- const res = await execInContainer(target, {
83
- script: mergeClaudeSettingsScript(),
84
- stdin: settingsJson
85
- });
86
- return res.ok ? { ok: true } : { ok: false, error: res.error };
70
+ /** Matches the delimited `id=<id>&state=` fragment of the hook's curl URL, so no other instance id can match by substring. */
71
+ export function hasAttentionHook(settings: Record<string, unknown> | null, id: string): boolean {
72
+ if (settings === null) return false;
73
+ return JSON.stringify(settings.hooks ?? null).includes(`id=${encodeURIComponent(id)}&state=`);
87
74
  }
88
75
 
89
76
  export const attentionHooks: Injection = {
@@ -97,8 +84,7 @@ export const attentionHooks: Injection = {
97
84
  log(`⚠ Claude hook injection failed: ${header.error}\n`);
98
85
  return;
99
86
  }
100
- const settings = attentionHookSettings(target.instance.id);
101
- const hooks = await injectClaudeHooks(target, settings);
87
+ const hooks = await mergeClaudeSettings(target, attentionHookSettings(target.instance.id));
102
88
  log(
103
89
  hooks.ok
104
90
  ? '✓ Claude attention hooks installed\n'
@@ -107,11 +93,6 @@ export const attentionHooks: Injection = {
107
93
  },
108
94
 
109
95
  async check(target) {
110
- return checkPresence(
111
- target,
112
- 'h=$(eval echo ~$(id -un)); d="${CLAUDE_CONFIG_DIR:-$h/.claude}"; ' +
113
- '[ -s "$d/settings.json" ] && grep -q "$1" "$d/settings.json" && echo 1 || echo 0',
114
- ['attention-check', target.instance.id]
115
- );
96
+ return hasAttentionHook(await readClaudeSettings(target), target.instance.id);
116
97
  }
117
98
  };
@@ -1,4 +1,8 @@
1
- import { checkPresence, execInContainer } from '../lib/exec.server.ts';
1
+ import {
2
+ appendLinesIfAbsent,
3
+ linesPresent,
4
+ SHELL_RC_FILES
5
+ } from '../lib/container-files.server.ts';
2
6
  import type { Injection } from '../lib/injections.server.ts';
3
7
 
4
8
  /** Both resolve `claude`, which `claude-skip-permissions` also aliases, so the two compose. */
@@ -7,37 +11,17 @@ const ALIAS_LINES = [
7
11
  "alias cs='claude --model sonnet'"
8
12
  ];
9
13
 
10
- /** Lines arrive as `$@` so no alias text is interpolated into the loop body. */
11
- const APPLY_SCRIPT =
12
- 'h=$(eval echo ~$(id -un)); ' +
13
- 'for line in "$@"; do ' +
14
- 'for f in "$h/.bashrc" "$h/.zshrc"; do ' +
15
- 'grep -qF "$line" "$f" 2>/dev/null || printf \'%s\\n\' "$line" >> "$f"; ' +
16
- 'done; ' +
17
- 'done';
18
-
19
- const CHECK_SCRIPT =
20
- 'h=$(eval echo ~$(id -un)); ' +
21
- 'for line in "$@"; do ' +
22
- 'grep -qF "$line" "$h/.bashrc" 2>/dev/null || grep -qF "$line" "$h/.zshrc" 2>/dev/null || ' +
23
- '{ echo 0; exit 0; }; ' +
24
- 'done; ' +
25
- 'echo 1';
26
-
27
14
  export const claudeAliases: Injection = {
28
15
  id: 'claude-aliases',
29
16
  label: 'claude aliases',
30
17
 
31
18
  async apply(target, log) {
32
19
  log('Installing c200 / cs claude aliases…\n');
33
- const res = await execInContainer(target, {
34
- script: APPLY_SCRIPT,
35
- args: ['claude-aliases', ...ALIAS_LINES]
36
- });
20
+ const res = await appendLinesIfAbsent(target, SHELL_RC_FILES, ALIAS_LINES);
37
21
  log(res.ok ? '✓ claude aliases installed\n' : `⚠ claude aliases setup failed: ${res.error}\n`);
38
22
  },
39
23
 
40
24
  async check(target) {
41
- return checkPresence(target, CHECK_SCRIPT, ['claude-aliases', ...ALIAS_LINES]);
25
+ return linesPresent(target, SHELL_RC_FILES, ALIAS_LINES);
42
26
  }
43
27
  };
@@ -4,7 +4,9 @@ import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { CLAUDE_CODE_TOKEN, CLAUDE_KEYCHAIN_SERVICE } from '../lib/config.server.ts';
6
6
  import { getOption } from '../lib/db.server.ts';
7
- import { checkPresence, execInContainer, writeSecretFileScript } from '../lib/exec.server.ts';
7
+ import { checkPresence } from '../lib/exec.server.ts';
8
+ import { deepMerge, editJsonFile, writeContainerFile } from '../lib/container-files.server.ts';
9
+ import { CLAUDE_JSON_FILE, claudeConfigFile } from '../lib/claude-settings.server.ts';
8
10
  import { spawnCapture } from '../lib/spawn.server.ts';
9
11
  import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
10
12
 
@@ -95,13 +97,15 @@ async function injectClaudeCredentials(
95
97
  target: ContainerTarget,
96
98
  creds: string
97
99
  ): Promise<{ ok: boolean; error?: string }> {
98
- const script =
99
- 'h=$(eval echo ~$(id -un)); d="${CLAUDE_CONFIG_DIR:-$h/.claude}"; ' +
100
- writeSecretFileScript('$d', '.credentials.json', '600') +
101
- ' cfg="${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}"; cfg="${cfg:-$h/.claude.json}"; ' +
102
- 'printf \'%s\' \'{"hasCompletedOnboarding":true}\' > "$cfg"; chmod 644 "$cfg"';
103
- const res = await execInContainer(target, { script, stdin: creds });
104
- return res.ok ? { ok: true } : { ok: false, error: res.error };
100
+ const wrote = await writeContainerFile(
101
+ target,
102
+ claudeConfigFile('.credentials.json', '600'),
103
+ creds
104
+ );
105
+ if (!wrote.ok) return wrote;
106
+ return editJsonFile(target, CLAUDE_JSON_FILE, (cur) =>
107
+ deepMerge(cur, { hasCompletedOnboarding: true })
108
+ );
105
109
  }
106
110
 
107
111
  /**
@@ -1,5 +1,13 @@
1
1
  import { getOption } from '../lib/db.server.ts';
2
- import { checkPresence, execInContainer } from '../lib/exec.server.ts';
2
+ import {
3
+ containerFileExists,
4
+ shellEnvFile,
5
+ deepMerge,
6
+ editJsonFile,
7
+ installShellEnvFile,
8
+ shellSingleQuote
9
+ } from '../lib/container-files.server.ts';
10
+ import { CLAUDE_JSON_FILE } from '../lib/claude-settings.server.ts';
3
11
  import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
4
12
 
5
13
  /** Mirrors the reference launcher script (claude-code.sh); each is overridable in Settings. */
@@ -35,57 +43,36 @@ export function customEndpointConfig(): {
35
43
  };
36
44
  }
37
45
 
38
- const ENV_FILE = '~/.codebay-claude-env';
46
+ const ENV_FILE_NAME = '.codebay-claude-env';
47
+
48
+ /** The whole file (token included) rides `stdin` inside `installShellEnvFile`, so nothing hits argv. */
49
+ function envFileContent(config: NonNullable<ReturnType<typeof customEndpointConfig>>): string {
50
+ return (
51
+ [
52
+ 'export DISABLE_AUTOUPDATER=1',
53
+ 'export CLAUDE_CODE_USE_BEDROCK=1',
54
+ 'export CLAUDE_CODE_SKIP_BEDROCK_AUTH=1',
55
+ `export ANTHROPIC_BEDROCK_BASE_URL=${shellSingleQuote(config.baseUrl)}`,
56
+ `export ANTHROPIC_AUTH_TOKEN=${shellSingleQuote(config.token)}`,
57
+ `export ANTHROPIC_DEFAULT_OPUS_MODEL=${shellSingleQuote(config.opusModel)}`,
58
+ `export ANTHROPIC_DEFAULT_SONNET_MODEL=${shellSingleQuote(config.sonnetModel)}`,
59
+ `export ANTHROPIC_DEFAULT_HAIKU_MODEL=${shellSingleQuote(config.haikuModel)}`,
60
+ `export ANTHROPIC_SMALL_FAST_MODEL=${shellSingleQuote(config.smallFastModel)}`,
61
+ `export ANTHROPIC_MODEL=${shellSingleQuote(config.defaultModel)}`
62
+ ].join('\n') + '\n'
63
+ );
64
+ }
39
65
 
40
- /**
41
- * Non-secret values ride `args`, so nothing needs hand-quoting; only the token uses `stdin`.
42
- * The file mixes secret and non-secret content, hence no `writeSecretFileScript`.
43
- */
44
66
  async function injectCustomEndpoint(
45
67
  target: ContainerTarget,
46
68
  config: NonNullable<ReturnType<typeof customEndpointConfig>>
47
69
  ): Promise<{ ok: boolean; error?: string }> {
48
- const script =
49
- 'set -e; f=$(eval echo "' +
50
- ENV_FILE +
51
- '"); ' +
52
- '{ ' +
53
- "printf '%s\\n' " +
54
- "'export DISABLE_AUTOUPDATER=1' " +
55
- "'export CLAUDE_CODE_USE_BEDROCK=1' " +
56
- "'export CLAUDE_CODE_SKIP_BEDROCK_AUTH=1'; " +
57
- 'printf \'export ANTHROPIC_BEDROCK_BASE_URL=%s\\n\' "$1"; ' +
58
- 'printf \'export ANTHROPIC_AUTH_TOKEN=%s\\n\' "$CODEBAY_STDIN"; ' +
59
- 'printf \'export ANTHROPIC_DEFAULT_OPUS_MODEL=%s\\n\' "$2"; ' +
60
- 'printf \'export ANTHROPIC_DEFAULT_SONNET_MODEL=%s\\n\' "$3"; ' +
61
- 'printf \'export ANTHROPIC_DEFAULT_HAIKU_MODEL=%s\\n\' "$4"; ' +
62
- 'printf \'export ANTHROPIC_SMALL_FAST_MODEL=%s\\n\' "$5"; ' +
63
- 'printf \'export ANTHROPIC_MODEL=%s\\n\' "$6"; ' +
64
- '} > "$f"; chmod 600 "$f"; ' +
65
- // The grep guard keeps a re-apply from stacking duplicate source lines.
66
- 'h=$(eval echo ~$(id -un)); src="[ -f \\"$f\\" ] && . \\"$f\\""; ' +
67
- 'for rc in "$h/.bashrc" "$h/.zshrc"; do ' +
68
- 'grep -qF "$src" "$rc" 2>/dev/null || printf \'%s\\n\' "$src" >> "$rc"; ' +
69
- 'done; ' +
70
- // `hasCompletedOnboarding` is what suppresses `claude`'s first-run wizard.
71
- 'h=$(eval echo ~$(id -un)); ' +
72
- 'cfg="${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}"; cfg="${cfg:-$h/.claude.json}"; ' +
73
- 'printf \'%s\' \'{"hasCompletedOnboarding":true}\' > "$cfg"; chmod 644 "$cfg"';
74
-
75
- const res = await execInContainer(target, {
76
- script,
77
- stdin: config.token,
78
- args: [
79
- 'claude-custom',
80
- config.baseUrl,
81
- config.opusModel,
82
- config.sonnetModel,
83
- config.haikuModel,
84
- config.smallFastModel,
85
- config.defaultModel
86
- ]
87
- });
88
- return res.ok ? { ok: true } : { ok: false, error: res.error };
70
+ const env = await installShellEnvFile(target, ENV_FILE_NAME, envFileContent(config));
71
+ if (!env.ok) return env;
72
+ // `hasCompletedOnboarding` is what suppresses `claude`'s first-run wizard.
73
+ return editJsonFile(target, CLAUDE_JSON_FILE, (cur) =>
74
+ deepMerge(cur, { hasCompletedOnboarding: true })
75
+ );
89
76
  }
90
77
 
91
78
  /** Replaces `claude-code-credentials` in the registry when the custom-endpoint setting is on. */
@@ -120,6 +107,6 @@ export const claudeCodeCustom: Injection = {
120
107
  },
121
108
 
122
109
  async check(target) {
123
- return checkPresence(target, `f=$(eval echo "${ENV_FILE}"); [ -s "$f" ] && echo 1 || echo 0`);
110
+ return containerFileExists(target, shellEnvFile(ENV_FILE_NAME));
124
111
  }
125
112
  };
@@ -0,0 +1,39 @@
1
+ import { checkPresence, execInContainer } from '../lib/exec.server.ts';
2
+ import type { Injection } from '../lib/injections.server.ts';
3
+
4
+ /** On Open VSX (code-server's default registry); the Marketplace build `/ide` targets is unreachable. */
5
+ export const EXTENSION_ID = 'anthropic.claude-code';
6
+
7
+ const EXT_GLOB = '~/.local/share/code-server/extensions/anthropic.claude-code-*';
8
+
9
+ /** Idempotent (skips if already present); best-effort — needs Open VSX egress. */
10
+ export const INSTALL_SCRIPT =
11
+ `if ls -d ${EXT_GLOB} >/dev/null 2>&1; then exit 0; fi; ` +
12
+ `command -v code-server >/dev/null 2>&1 || { echo "code-server not found" >&2; exit 1; }; ` +
13
+ `code-server --install-extension ${EXTENSION_ID}`;
14
+
15
+ export const CHECK_SCRIPT = `ls -d ${EXT_GLOB} >/dev/null 2>&1 && echo 1 || echo 0`;
16
+
17
+ /**
18
+ * Runtime fallback to the build-time launch-line install in CODE_SERVER_LAUNCH; failure is
19
+ * non-fatal since the IDE integration is optional and everything else works without it.
20
+ */
21
+ export const claudeCodeIdeExtension: Injection = {
22
+ id: 'claude-code-ide-extension',
23
+ label: 'Claude Code IDE extension',
24
+
25
+ async apply(target, log) {
26
+ log('Installing Claude Code IDE extension…\n');
27
+ // Full target (with remoteUser) so it lands in that user's code-server home, where code-server reads it.
28
+ const res = await execInContainer(target, { script: INSTALL_SCRIPT });
29
+ log(
30
+ res.ok
31
+ ? '✓ Claude Code IDE extension installed\n'
32
+ : `⚠ Claude Code IDE extension install failed: ${res.error} — /ide integration off\n`
33
+ );
34
+ },
35
+
36
+ async check(target) {
37
+ return checkPresence(target, CHECK_SCRIPT);
38
+ }
39
+ };
@@ -1,5 +1,5 @@
1
1
  import { getOption } from '../lib/db.server.ts';
2
- import { execInContainer } from '../lib/exec.server.ts';
2
+ import { installShellEnvFile, shellSingleQuote } from '../lib/container-files.server.ts';
3
3
  import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
4
4
 
5
5
  /** The option key → Claude env var mapping; order is the field order in Settings. */
@@ -26,33 +26,17 @@ export function manualModelConfig(): Record<string, string> | null {
26
26
  return Object.keys(filled).length ? filled : null;
27
27
  }
28
28
 
29
- const ENV_FILE = '~/.codebay-claude-models-env';
29
+ const ENV_FILE_NAME = '.codebay-claude-models-env';
30
30
 
31
- /** Model IDs are non-secret, so every value rides `args` ($1, $2, …) — no stdin. */
32
- async function injectModels(
31
+ function injectModels(
33
32
  target: ContainerTarget,
34
33
  config: Record<string, string>
35
34
  ): Promise<{ ok: boolean; error?: string }> {
36
- const entries = Object.entries(config);
37
- const writes = entries.map(([env], i) => `printf 'export ${env}=%s\\n' "$${i + 1}"; `).join('');
38
- const script =
39
- 'set -e; f=$(eval echo "' +
40
- ENV_FILE +
41
- '"); ' +
42
- '{ ' +
43
- writes +
44
- '} > "$f"; chmod 600 "$f"; ' +
45
- // The grep guard keeps a re-apply from stacking duplicate source lines.
46
- 'h=$(eval echo ~$(id -un)); src="[ -f \\"$f\\" ] && . \\"$f\\""; ' +
47
- 'for rc in "$h/.bashrc" "$h/.zshrc"; do ' +
48
- 'grep -qF "$src" "$rc" 2>/dev/null || printf \'%s\\n\' "$src" >> "$rc"; ' +
49
- 'done';
50
-
51
- const res = await execInContainer(target, {
52
- script,
53
- args: ['claude-models', ...entries.map(([, value]) => value)]
54
- });
55
- return res.ok ? { ok: true } : { ok: false, error: res.error };
35
+ const content =
36
+ Object.entries(config)
37
+ .map(([env, value]) => `export ${env}=${shellSingleQuote(value)}`)
38
+ .join('\n') + '\n';
39
+ return installShellEnvFile(target, ENV_FILE_NAME, content);
56
40
  }
57
41
 
58
42
  /** Sets Claude model env vars on the standard path; skipped when LiteLLM is active. */
@@ -1,8 +1,7 @@
1
1
  import { getOption } from '../lib/db.server.ts';
2
- import { checkPresence, execInContainer } from '../lib/exec.server.ts';
3
- import { mergeClaudeSettingsScript } from './attention-hooks.ts';
2
+ import { mergeClaudeSettings, readClaudeSettings } from '../lib/claude-settings.server.ts';
4
3
  import { readHostClaudeSettings } from './claude-statusline.ts';
5
- import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
4
+ import type { Injection } from '../lib/injections.server.ts';
6
5
 
7
6
  /** The app's own model config (manual override / LiteLLM) exports ANTHROPIC_MODEL, which wins over settings.json. */
8
7
  function appOwnsModel(): boolean {
@@ -20,35 +19,16 @@ export async function hostClaudeModel(): Promise<string | null> {
20
19
  return typeof model === 'string' && model.trim() ? model.trim() : null;
21
20
  }
22
21
 
23
- async function injectModel(
24
- target: ContainerTarget,
25
- model: string
26
- ): Promise<{ ok: boolean; error?: string }> {
27
- const res = await execInContainer(target, {
28
- script: mergeClaudeSettingsScript(),
29
- stdin: JSON.stringify({ model })
30
- });
31
- return res.ok ? { ok: true } : { ok: false, error: res.error };
32
- }
33
-
34
22
  /** Mirrors the host's default `model` into the container so subscription instances match the host. */
35
23
  export const claudeModel: Injection = {
36
24
  id: 'claude-model',
37
25
  label: 'model default',
38
26
 
39
- auth: {
40
- hint: 'set `model` in ~/.claude/settings.json',
41
- async status() {
42
- const model = await hostClaudeModel();
43
- return { available: model !== null, source: model ? '~/.claude/settings.json' : null };
44
- }
45
- },
46
-
47
27
  async apply(target, log) {
48
28
  const model = await hostClaudeModel();
49
29
  if (!model) return;
50
30
  log(`Injecting Claude model default (${model})…\n`);
51
- const result = await injectModel(target, model);
31
+ const result = await mergeClaudeSettings(target, { model });
52
32
  log(
53
33
  result.ok
54
34
  ? '✓ Claude model default configured in container\n'
@@ -57,10 +37,7 @@ export const claudeModel: Injection = {
57
37
  },
58
38
 
59
39
  async check(target) {
60
- return checkPresence(
61
- target,
62
- 'h=$(eval echo ~$(id -un)); d="${CLAUDE_CONFIG_DIR:-$h/.claude}"; ' +
63
- '[ -s "$d/settings.json" ] && grep -q \'"model"\' "$d/settings.json" && echo 1 || echo 0'
64
- );
40
+ const settings = await readClaudeSettings(target);
41
+ return typeof settings?.model === 'string' && settings.model.trim() !== '';
65
42
  }
66
43
  };
@@ -1,6 +1,5 @@
1
- import { checkPresence, execInContainer } from '../lib/exec.server.ts';
2
- import { mergeClaudeSettingsScript } from './attention-hooks.ts';
3
- import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
1
+ import { mergeClaudeSettings, readClaudeSettings } from '../lib/claude-settings.server.ts';
2
+ import type { Injection } from '../lib/injections.server.ts';
4
3
 
5
4
  /**
6
5
  * Both schemas on purpose: newer `claude` reads the nested `attribution` object, older builds
@@ -12,14 +11,6 @@ export const NO_COAUTHOR_SETTINGS = {
12
11
  attribution: { commit: '', pr: '', includeCoAuthoredBy: false }
13
12
  };
14
13
 
15
- async function injectNoCoauthor(target: ContainerTarget): Promise<{ ok: boolean; error?: string }> {
16
- const res = await execInContainer(target, {
17
- script: mergeClaudeSettingsScript(),
18
- stdin: JSON.stringify(NO_COAUTHOR_SETTINGS)
19
- });
20
- return res.ok ? { ok: true } : { ok: false, error: res.error };
21
- }
22
-
23
14
  /** A Codebay-wide default rather than an opt-in setting, hence no `auth` block. */
24
15
  export const claudeNoCoauthor: Injection = {
25
16
  id: 'claude-no-coauthor',
@@ -27,7 +18,7 @@ export const claudeNoCoauthor: Injection = {
27
18
 
28
19
  async apply(target, log) {
29
20
  log('Disabling Claude co-author commit byline…\n');
30
- const result = await injectNoCoauthor(target);
21
+ const result = await mergeClaudeSettings(target, NO_COAUTHOR_SETTINGS);
31
22
  log(
32
23
  result.ok
33
24
  ? '✓ Claude co-author byline disabled\n'
@@ -36,10 +27,7 @@ export const claudeNoCoauthor: Injection = {
36
27
  },
37
28
 
38
29
  async check(target) {
39
- return checkPresence(
40
- target,
41
- 'h=$(eval echo ~$(id -un)); d="${CLAUDE_CONFIG_DIR:-$h/.claude}"; ' +
42
- '[ -s "$d/settings.json" ] && grep -q includeCoAuthoredBy "$d/settings.json" && echo 1 || echo 0'
43
- );
30
+ const settings = await readClaudeSettings(target);
31
+ return settings !== null && 'includeCoAuthoredBy' in settings;
44
32
  }
45
33
  };
@@ -1,22 +1,12 @@
1
- import { checkPresence, execInContainer } from '../lib/exec.server.ts';
1
+ import {
2
+ appendLinesIfAbsent,
3
+ linesPresent,
4
+ SHELL_RC_FILES
5
+ } from '../lib/container-files.server.ts';
2
6
  import type { Injection } from '../lib/injections.server.ts';
3
7
 
4
8
  const ALIAS_LINE = "alias claude='claude --dangerously-skip-permissions'";
5
9
 
6
- /** Both rc files, since either shell may be the one code-server opens. */
7
- const APPLY_SCRIPT =
8
- 'h=$(eval echo ~$(id -un)); ' +
9
- `line="${ALIAS_LINE}"; ` +
10
- 'for f in "$h/.bashrc" "$h/.zshrc"; do ' +
11
- 'grep -qF "$line" "$f" 2>/dev/null || printf \'%s\\n\' "$line" >> "$f"; ' +
12
- 'done';
13
-
14
- const CHECK_SCRIPT =
15
- 'h=$(eval echo ~$(id -un)); ' +
16
- `line="${ALIAS_LINE}"; ` +
17
- 'if grep -qF "$line" "$h/.bashrc" 2>/dev/null || grep -qF "$line" "$h/.zshrc" 2>/dev/null; ' +
18
- 'then echo 1; else echo 0; fi';
19
-
20
10
  /** Safe here only because instances are throwaway, single-tenant sandboxes. */
21
11
  export const claudeSkipPermissions: Injection = {
22
12
  id: 'claude-skip-permissions',
@@ -24,7 +14,7 @@ export const claudeSkipPermissions: Injection = {
24
14
 
25
15
  async apply(target, log) {
26
16
  log('Installing claude --dangerously-skip-permissions alias…\n');
27
- const res = await execInContainer(target, { script: APPLY_SCRIPT });
17
+ const res = await appendLinesIfAbsent(target, SHELL_RC_FILES, [ALIAS_LINE]);
28
18
  log(
29
19
  res.ok
30
20
  ? '✓ claude skip-permissions alias installed\n'
@@ -33,6 +23,6 @@ export const claudeSkipPermissions: Injection = {
33
23
  },
34
24
 
35
25
  async check(target) {
36
- return checkPresence(target, CHECK_SCRIPT);
26
+ return linesPresent(target, SHELL_RC_FILES, [ALIAS_LINE]);
37
27
  }
38
28
  };