chamba 0.3.0 → 0.4.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/LICENSE +21 -0
- package/README.md +47 -44
- package/bin/chamba.js +212 -0
- package/dist/commands/advanced.js +278 -0
- package/dist/commands/dev.js +619 -0
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/menu.js +80 -0
- package/dist/commands/onboard.js +229 -0
- package/dist/commands/settings.js +349 -0
- package/dist/lib/agent-context.js +177 -0
- package/dist/lib/browser.js +40 -0
- package/dist/lib/chamba-yaml.js +191 -0
- package/dist/lib/constants.js +135 -0
- package/dist/lib/dockerfile-builder.js +267 -0
- package/dist/lib/env.js +78 -0
- package/dist/lib/global-config.js +66 -0
- package/dist/lib/pnpm-store.js +19 -0
- package/dist/lib/ports.js +210 -0
- package/dist/lib/safe-rm.js +26 -0
- package/dist/lib/sessions.js +34 -0
- package/dist/lib/shadows.js +174 -0
- package/dist/lib/webterm.js +490 -0
- package/dist/lib/workspace-identity.js +260 -0
- package/package.json +61 -24
- package/schema/chamba.schema.json +65 -0
- package/templates/.dockerignore +3 -0
- package/templates/Dockerfile +173 -0
- package/templates/claude-statusline.sh +120 -0
- package/templates/context/baseline.md +13 -0
- package/templates/context/context-usage.md +1 -0
- package/templates/context/git-mode-local.md +1 -0
- package/templates/context/git-mode-strict.md +1 -0
- package/templates/context/git-mode-unrestricted.md +1 -0
- package/templates/context/git-unavailable.md +1 -0
- package/templates/context/shadow-paths.md +3 -0
- package/templates/context-usage.sh +249 -0
- package/templates/git-readonly-wrapper.mjs +309 -0
- package/templates/npmrc +2 -0
- package/templates/pnpm-config.yaml +9 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/skills/chamba-statusline/SKILL.md +79 -0
- package/templates/skills/context-usage/SKILL.md +53 -0
- package/templates/skills/web-pane/SKILL.md +62 -0
- package/templates/startup-git-mode.mjs +145 -0
- package/templates/startup.mjs +333 -0
- package/templates/webpane.sh +126 -0
- package/templates/webterm/README.md +157 -0
- package/templates/webterm/artifacts.js +583 -0
- package/templates/webterm/config.js +269 -0
- package/templates/webterm/context/claude.md +14 -0
- package/templates/webterm/conversation.js +248 -0
- package/templates/webterm/package-lock.json +884 -0
- package/templates/webterm/package.json +17 -0
- package/templates/webterm/pane.js +156 -0
- package/templates/webterm/proc.js +89 -0
- package/templates/webterm/public/app/alerts.js +472 -0
- package/templates/webterm/public/app/cards.js +123 -0
- package/templates/webterm/public/app/clipboard.js +229 -0
- package/templates/webterm/public/app/composer.js +226 -0
- package/templates/webterm/public/app/connection.js +342 -0
- package/templates/webterm/public/app/dictation.js +98 -0
- package/templates/webterm/public/app/dom.js +37 -0
- package/templates/webterm/public/app/drafts.js +244 -0
- package/templates/webterm/public/app/frames.js +166 -0
- package/templates/webterm/public/app/main.js +82 -0
- package/templates/webterm/public/app/new-session.js +188 -0
- package/templates/webterm/public/app/note.js +24 -0
- package/templates/webterm/public/app/pane-frame.js +166 -0
- package/templates/webterm/public/app/pane.js +353 -0
- package/templates/webterm/public/app/state.js +51 -0
- package/templates/webterm/public/app/status-strip.js +170 -0
- package/templates/webterm/public/app/tabs.js +475 -0
- package/templates/webterm/public/app/terminal.js +102 -0
- package/templates/webterm/public/app/theme.js +46 -0
- package/templates/webterm/public/favicon.svg +21 -0
- package/templates/webterm/public/index.html +105 -0
- package/templates/webterm/public/styles.css +1193 -0
- package/templates/webterm/server.js +1142 -0
- package/templates/webterm/sessions.js +515 -0
- package/templates/webterm/snapshot.js +135 -0
- package/templates/webterm.sh +167 -0
- package/dist/cli.js +0 -1691
- package/dist/server.js +0 -1919
- package/inject/annotate.js +0 -18
- package/skill/README.md +0 -12
- package/skill/SKILL.md +0 -93
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
- package/web/assets/index-B9DI4F1Z.js +0 -202
- package/web/assets/index-DK_n6CTo.css +0 -2
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
- package/web/index.html +0 -28
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Agent mount definitions and context injection for AI CLIs running inside chamba containers.
|
|
2
|
+
//
|
|
3
|
+
// This file is the single source of truth for which directories each AI CLI reads/writes
|
|
4
|
+
// and how chamba intercepts them via bind mounts. If an AI CLI changes its config layout,
|
|
5
|
+
// this file must be updated.
|
|
6
|
+
//
|
|
7
|
+
// Verify against official docs periodically:
|
|
8
|
+
// Claude Code: https://docs.anthropic.com/en/docs/claude-code
|
|
9
|
+
// OpenCode: https://github.com/opencode-ai/opencode
|
|
10
|
+
// Codex: https://github.com/openai/codex
|
|
11
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { AGENTS_DIR, CLAUDE_STATUSLINE_PATH, CONTAINER_HOME, GIT_MODE, PACKAGE_ROOT } from "./constants.js";
|
|
14
|
+
export const AGENT_MOUNTS = [
|
|
15
|
+
{
|
|
16
|
+
agent: "claude",
|
|
17
|
+
hostSubpath: "claude",
|
|
18
|
+
container: `${CONTAINER_HOME}/.claude`,
|
|
19
|
+
description: "Claude Code user-level config and session data",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
agent: "opencode",
|
|
23
|
+
hostSubpath: "opencode/config",
|
|
24
|
+
container: `${CONTAINER_HOME}/.config/opencode`,
|
|
25
|
+
description: "OpenCode user-level config",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
agent: "opencode",
|
|
29
|
+
hostSubpath: "opencode/data",
|
|
30
|
+
container: `${CONTAINER_HOME}/.local/share/opencode`,
|
|
31
|
+
description: "OpenCode user-level data and session history",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
agent: "codex",
|
|
35
|
+
hostSubpath: "codex",
|
|
36
|
+
container: `${CONTAINER_HOME}/.codex`,
|
|
37
|
+
description: "Codex user-level config and session data",
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
export const AGENT_FILE_MOUNTS = [
|
|
41
|
+
{
|
|
42
|
+
// .claude.json lives outside ~/.claude/ so it is not covered by the directory mount.
|
|
43
|
+
// Persisting it as a file mount avoids losing Claude Code settings on container rebuild.
|
|
44
|
+
hostSubpath: "claude/.claude.json",
|
|
45
|
+
container: `${CONTAINER_HOME}/.claude.json`,
|
|
46
|
+
initialContent: "{}\n",
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
// --- Build agent mount args --------------------------------------------------------------------------------------------------------------
|
|
50
|
+
/**
|
|
51
|
+
* Creates agents/ subdirectories in the workspace cache dir on the host (lazily) and
|
|
52
|
+
* returns volume mount args for all supported agent tools.
|
|
53
|
+
*/
|
|
54
|
+
export function buildAgentMountArgs(workspaceDir) {
|
|
55
|
+
const agentsDir = join(workspaceDir, AGENTS_DIR);
|
|
56
|
+
const mounts = AGENT_MOUNTS.map((m) => ({
|
|
57
|
+
host: join(agentsDir, m.hostSubpath),
|
|
58
|
+
container: m.container,
|
|
59
|
+
}));
|
|
60
|
+
for (const { host } of mounts)
|
|
61
|
+
mkdirSync(host, { recursive: true });
|
|
62
|
+
const fileMounts = AGENT_FILE_MOUNTS.map((m) => ({
|
|
63
|
+
host: join(agentsDir, m.hostSubpath),
|
|
64
|
+
container: m.container,
|
|
65
|
+
initialContent: m.initialContent,
|
|
66
|
+
}));
|
|
67
|
+
for (const { host, initialContent } of fileMounts) {
|
|
68
|
+
if (!existsSync(host))
|
|
69
|
+
writeFileSync(host, initialContent);
|
|
70
|
+
}
|
|
71
|
+
return [
|
|
72
|
+
...mounts.flatMap(({ host, container }) => ["-v", `${host}:${container}`]),
|
|
73
|
+
...fileMounts.flatMap(({ host, container }) => ["-v", `${host}:${container}`]),
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
// --- Template helpers --------------------------------------------------------------------------------------------------------------------
|
|
77
|
+
function loadTemplate(name) {
|
|
78
|
+
return readFileSync(join(PACKAGE_ROOT, "templates", "context", `${name}.md`), "utf8").trimEnd();
|
|
79
|
+
}
|
|
80
|
+
function renderTemplate(template, vars) {
|
|
81
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
|
|
82
|
+
}
|
|
83
|
+
function writeFileEnsuringDir(filePath, content) {
|
|
84
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
85
|
+
writeFileSync(filePath, content);
|
|
86
|
+
}
|
|
87
|
+
// --- Build agent context documents -------------------------------------------------------------------------------------------------------
|
|
88
|
+
/**
|
|
89
|
+
* Assembles the agent context markdown injected into each supported agent's config dir at session start.
|
|
90
|
+
*/
|
|
91
|
+
export function buildAgentContextDocs(hasGit, shadowPatterns, gitMode = GIT_MODE.local) {
|
|
92
|
+
const gitSection = loadTemplate(hasGit ? `git-mode-${gitMode}` : "git-unavailable");
|
|
93
|
+
const shadowSection = shadowPatterns && shadowPatterns.length > 0
|
|
94
|
+
? renderTemplate(loadTemplate("shadow-paths"), {
|
|
95
|
+
// Indented two spaces to nest under the section's leading bullet.
|
|
96
|
+
pattern_list: shadowPatterns.map((p) => ` - \`${p}\``).join("\n"),
|
|
97
|
+
})
|
|
98
|
+
: null;
|
|
99
|
+
const baselineTemplate = loadTemplate("baseline");
|
|
100
|
+
// The doc is one bullet list: baseline holds the unconditional bullets, followed by the
|
|
101
|
+
// conditional ones (shadow paths, git mode) and any agent-specific extras.
|
|
102
|
+
function build(toolPath, extraSections = []) {
|
|
103
|
+
const sections = [
|
|
104
|
+
renderTemplate(baselineTemplate, { tool_path: toolPath }),
|
|
105
|
+
...(shadowSection ? [shadowSection] : []),
|
|
106
|
+
gitSection,
|
|
107
|
+
...extraSections,
|
|
108
|
+
];
|
|
109
|
+
return `${sections.join("\n")}\n`;
|
|
110
|
+
}
|
|
111
|
+
// The context-usage section is Claude-only: the snapshot it points at is written by the status line
|
|
112
|
+
// script Claude Code runs, so other agents' sessions never refresh it.
|
|
113
|
+
return {
|
|
114
|
+
claude: build("~/.claude/CLAUDE.md", [loadTemplate("context-usage")]),
|
|
115
|
+
opencode: build("~/.config/opencode/AGENTS.md"),
|
|
116
|
+
codex: build("~/.codex/AGENTS.md"),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// --- Claude settings.json bootstrap ------------------------------------------------------------------------------------------------------
|
|
120
|
+
function readSettingsObject(path) {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
123
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
124
|
+
return parsed;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Missing file or malformed JSON: caller proceeds with an empty object.
|
|
129
|
+
}
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
// Sets ~/.claude/settings.json -> statusLine to the chamba default if no value is set.
|
|
133
|
+
// Non-destructive: preserves other fields and never overwrites a user-set statusLine.
|
|
134
|
+
export function ensureClaudeStatusLine(workspaceDir) {
|
|
135
|
+
const settingsPath = join(workspaceDir, AGENTS_DIR, "claude", "settings.json");
|
|
136
|
+
const settings = readSettingsObject(settingsPath);
|
|
137
|
+
if (settings.statusLine === undefined) {
|
|
138
|
+
settings.statusLine = { type: "command", command: CLAUDE_STATUSLINE_PATH };
|
|
139
|
+
writeFileEnsuringDir(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// --- Claude skills injection -------------------------------------------------------------------------------------------------------------
|
|
143
|
+
// Renders every templates/skills/<name>/SKILL.md into agents/claude/skills/<name>/SKILL.md. Drop a
|
|
144
|
+
// new directory under templates/skills/ to ship a new skill - no other code changes needed.
|
|
145
|
+
export function injectClaudeSkills(workspaceDir) {
|
|
146
|
+
const templatesSkillsDir = join(PACKAGE_ROOT, "templates", "skills");
|
|
147
|
+
if (!existsSync(templatesSkillsDir))
|
|
148
|
+
return;
|
|
149
|
+
const targetSkillsDir = join(workspaceDir, AGENTS_DIR, "claude", "skills");
|
|
150
|
+
const vars = { statusline_path: CLAUDE_STATUSLINE_PATH };
|
|
151
|
+
for (const entry of readdirSync(templatesSkillsDir, { withFileTypes: true })) {
|
|
152
|
+
if (!entry.isDirectory())
|
|
153
|
+
continue;
|
|
154
|
+
const sourcePath = join(templatesSkillsDir, entry.name, "SKILL.md");
|
|
155
|
+
if (!existsSync(sourcePath))
|
|
156
|
+
continue;
|
|
157
|
+
const rendered = renderTemplate(readFileSync(sourcePath, "utf8"), vars);
|
|
158
|
+
writeFileEnsuringDir(join(targetSkillsDir, entry.name, "SKILL.md"), rendered);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// --- Inject agent context ----------------------------------------------------------------------------------------------------------------
|
|
162
|
+
/**
|
|
163
|
+
* Writes agent context markdown files into the workspace's agents/ directory.
|
|
164
|
+
*/
|
|
165
|
+
export function injectAgentContext(workspaceDir, docs) {
|
|
166
|
+
const a = join(workspaceDir, AGENTS_DIR);
|
|
167
|
+
const files = [
|
|
168
|
+
{ path: join(a, "claude", "CLAUDE.md"), content: docs.claude },
|
|
169
|
+
{ path: join(a, "opencode", "config", "AGENTS.md"), content: docs.opencode },
|
|
170
|
+
{ path: join(a, "codex", "AGENTS.md"), content: docs.codex },
|
|
171
|
+
];
|
|
172
|
+
for (const { path: filePath, content } of files) {
|
|
173
|
+
writeFileEnsuringDir(filePath, content);
|
|
174
|
+
}
|
|
175
|
+
ensureClaudeStatusLine(workspaceDir);
|
|
176
|
+
injectClaudeSkills(workspaceDir);
|
|
177
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/browser.ts - Open a URL in the host's default browser
|
|
3
|
+
// One tiny best-effort step of the session start: chamba resolves the web port, brings the interface up,
|
|
4
|
+
// and hands the URL to whatever the platform uses to open links. Never blocks and never fails a session.
|
|
5
|
+
// =========================================================================================================================================
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
// A hung opener would sit in front of the user's shell, so give it a short deadline and move on. Every
|
|
8
|
+
// opener below hands the URL to a running desktop and returns at once, so this only bites when something
|
|
9
|
+
// is wrong - and then printing the URL is the better outcome anyway.
|
|
10
|
+
const OPEN_TIMEOUT_MS = 3000;
|
|
11
|
+
/**
|
|
12
|
+
* The argv that opens a URL in the default browser on this platform, or null when we know of no opener for
|
|
13
|
+
* it. Argv, never a shell string: the URL carries a key read back from a file every process in the
|
|
14
|
+
* container can write, so it must reach the opener as one argument and never as something a shell parses.
|
|
15
|
+
* On Windows the opener is `start`, which is a cmd builtin rather than a program, hence the `cmd /c` -
|
|
16
|
+
* its first quoted argument is the window title, which is why an empty one goes before the URL.
|
|
17
|
+
*/
|
|
18
|
+
export function browserOpenArgv(url, platform = process.platform) {
|
|
19
|
+
if (platform === "darwin")
|
|
20
|
+
return ["open", url];
|
|
21
|
+
if (platform === "win32")
|
|
22
|
+
return ["cmd", "/c", "start", "", url];
|
|
23
|
+
if (platform === "linux")
|
|
24
|
+
return ["xdg-open", url];
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Open a URL in the host's default browser. Returns false when there is no opener for this platform, the
|
|
29
|
+
* opener is not installed (a headless host, an SSH session), or it failed - the caller then prints the URL
|
|
30
|
+
* and carries on. Nothing here is allowed to throw: opening the browser for the user is the convenience, and
|
|
31
|
+
* the printed URL reaches the same interface, so a session must start either way.
|
|
32
|
+
*/
|
|
33
|
+
export function openInBrowser(url, platform = process.platform) {
|
|
34
|
+
const argv = browserOpenArgv(url, platform);
|
|
35
|
+
if (argv === null)
|
|
36
|
+
return false;
|
|
37
|
+
const [command = "", ...args] = argv;
|
|
38
|
+
const result = spawnSync(command, args, { stdio: "ignore", timeout: OPEN_TIMEOUT_MS });
|
|
39
|
+
return result.error === undefined && result.status === 0;
|
|
40
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/chamba-yaml.ts - Read, write, and validate chamba.yaml
|
|
3
|
+
// =========================================================================================================================================
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
import AjvModule from "ajv";
|
|
7
|
+
import { dump as dumpYaml, load as loadYaml } from "js-yaml";
|
|
8
|
+
import { CHAMBA_SCHEMA_DIR, CHAMBA_SCHEMA_FILE, CHAMBA_YAML, DEFAULT_SHADOW_PATHS, PACKAGE_ROOT, WORKSPACE_ID_MAX, WORKSPACE_ID_MIN, } from "./constants.js";
|
|
9
|
+
// --- Validation --------------------------------------------------------------------------------------------------------------------------
|
|
10
|
+
// Must match the pattern, minLength, and maxLength in schema/chamba.schema.json
|
|
11
|
+
const WORKSPACE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
|
12
|
+
/** Returns error message if invalid, undefined if valid. Used for interactive input validation. */
|
|
13
|
+
export function validateWorkspaceId(id) {
|
|
14
|
+
if (id.length < WORKSPACE_ID_MIN)
|
|
15
|
+
return `Workspace ID must be at least ${WORKSPACE_ID_MIN} characters`;
|
|
16
|
+
if (id.length > WORKSPACE_ID_MAX)
|
|
17
|
+
return `Workspace ID must be at most ${WORKSPACE_ID_MAX} characters`;
|
|
18
|
+
if (!WORKSPACE_ID_PATTERN.test(id))
|
|
19
|
+
return "Workspace ID must be lowercase alphanumeric with hyphens, not starting or ending with a hyphen";
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
/** Slugify a directory name into a valid workspace_id candidate. */
|
|
23
|
+
export function slugifyForWorkspaceId(name) {
|
|
24
|
+
return (name
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
27
|
+
.replace(/^-+|-+$/g, "")
|
|
28
|
+
.slice(0, WORKSPACE_ID_MAX) || "my-workspace");
|
|
29
|
+
}
|
|
30
|
+
// --- Schema validation -------------------------------------------------------------------------------------------------------------------
|
|
31
|
+
const schemaPath = join(PACKAGE_ROOT, CHAMBA_SCHEMA_DIR, CHAMBA_SCHEMA_FILE);
|
|
32
|
+
// ajv is a CJS module - under nodenext resolution the class is nested under .default
|
|
33
|
+
const Ajv = AjvModule.default ?? AjvModule;
|
|
34
|
+
let _validate = null;
|
|
35
|
+
/** Lazily compile and cache the JSON Schema validator. */
|
|
36
|
+
function getValidator() {
|
|
37
|
+
if (!_validate) {
|
|
38
|
+
const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
|
|
39
|
+
// allowUnionTypes: the `ports` port field is intentionally an integer|string union (bare identity port or
|
|
40
|
+
// "HOST:CONTAINER" string); the schema keeps only coarse structure while ports.ts owns range/format semantics.
|
|
41
|
+
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true });
|
|
42
|
+
_validate = ajv.compile(schema);
|
|
43
|
+
}
|
|
44
|
+
return _validate;
|
|
45
|
+
}
|
|
46
|
+
// --- Validation helpers ------------------------------------------------------------------------------------------------------------------
|
|
47
|
+
/** Format ajv validation errors into a human-readable string. */
|
|
48
|
+
// biome-ignore lint/suspicious/noExplicitAny: ajv error objects lack a stable exported type
|
|
49
|
+
function formatValidationErrors(errors) {
|
|
50
|
+
return (errors ?? [])
|
|
51
|
+
.map((e) => {
|
|
52
|
+
if (e.keyword === "additionalProperties" && e.params?.additionalProperty) {
|
|
53
|
+
return `unknown property "${e.params.additionalProperty}"`;
|
|
54
|
+
}
|
|
55
|
+
const path = e.instancePath ? `"${e.instancePath.slice(1).replace(/\//g, ".")}"` : "";
|
|
56
|
+
return path ? `${path} ${e.message}` : (e.message ?? "validation error");
|
|
57
|
+
})
|
|
58
|
+
.join("; ");
|
|
59
|
+
}
|
|
60
|
+
// --- Read --------------------------------------------------------------------------------------------------------------------------------
|
|
61
|
+
/** Read and validate chamba.yaml from a directory. Returns null if file missing. Throws if invalid. */
|
|
62
|
+
export function readChambaYaml(dir) {
|
|
63
|
+
const filePath = join(dir, CHAMBA_YAML);
|
|
64
|
+
if (!existsSync(filePath))
|
|
65
|
+
return null;
|
|
66
|
+
const raw = loadYaml(readFileSync(filePath, "utf8"));
|
|
67
|
+
if (typeof raw !== "object" || raw === null) {
|
|
68
|
+
throw new Error(`${CHAMBA_YAML} is empty or not a valid YAML object`);
|
|
69
|
+
}
|
|
70
|
+
// Validate against JSON Schema
|
|
71
|
+
const validate = getValidator();
|
|
72
|
+
if (!validate(raw)) {
|
|
73
|
+
throw new Error(`Invalid ${CHAMBA_YAML}: ${formatValidationErrors(validate.errors)}`);
|
|
74
|
+
}
|
|
75
|
+
return raw;
|
|
76
|
+
}
|
|
77
|
+
// --- Write -------------------------------------------------------------------------------------------------------------------------------
|
|
78
|
+
// Editor schema header. It points into the published npm package through a public CDN rather than at a
|
|
79
|
+
// repository URL: this repo is private and carries no release tags, so nothing a git host serves would
|
|
80
|
+
// resolve. The URL is pinned by the package's own version, so an installed chamba always names the schema
|
|
81
|
+
// it actually ships, and the pin needs no upkeep - the version string is already there.
|
|
82
|
+
const { version } = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
83
|
+
export const SCHEMA_URL = `https://unpkg.com/chamba@${version}/schema/${CHAMBA_SCHEMA_FILE}`;
|
|
84
|
+
const SCHEMA_HEADER = `# yaml-language-server: $schema=${SCHEMA_URL}`;
|
|
85
|
+
// Inline comments injected before specific YAML keys (preceded by a blank line)
|
|
86
|
+
const YAML_COMMENTS = {
|
|
87
|
+
workspace_id: "# chamba workspace config - run 'npx chamba' from anywhere under this directory tree to start your dev container.\n" +
|
|
88
|
+
"# Every field is documented in the schema above, which your editor reads for completion and validation.\n" +
|
|
89
|
+
"# This file may be rewritten by chamba (repair, reset, settings changes). Custom comments will not be preserved.",
|
|
90
|
+
env: "# Environment injected into the container - a single value or a list.\n" +
|
|
91
|
+
"# Each entry is an env-file path relative to this directory (e.g. '.env') or an inline 'KEY=VALUE' variable.",
|
|
92
|
+
shadow_paths: "# .gitignore-style patterns - agents see an empty, isolated copy instead of the real host data.",
|
|
93
|
+
profiles: "# Dockerfile profiles - each adds on top of the chamba base image (Debian + Node.js + git + AI CLIs).\n" +
|
|
94
|
+
"# Useful for teams: each person can have a lean profile for their stack instead of one large shared image.\n" +
|
|
95
|
+
"# When multiple profiles exist, chamba prompts you to pick one on session start.",
|
|
96
|
+
};
|
|
97
|
+
/** Write chamba.yaml to a directory with schema header and inline comments. */
|
|
98
|
+
export function writeChambaYaml(dir, config) {
|
|
99
|
+
const filePath = join(dir, CHAMBA_YAML);
|
|
100
|
+
const yamlContent = dumpYaml(config, {
|
|
101
|
+
lineWidth: -1,
|
|
102
|
+
quotingType: '"',
|
|
103
|
+
forceQuotes: false,
|
|
104
|
+
});
|
|
105
|
+
// Inject blank lines and comments before known keys
|
|
106
|
+
const lines = yamlContent.split("\n");
|
|
107
|
+
const output = [];
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const key = line.match(/^(\w[\w_]*):/)?.[1];
|
|
110
|
+
const comment = key && YAML_COMMENTS[key];
|
|
111
|
+
if (comment) {
|
|
112
|
+
output.push(""); // blank line before section
|
|
113
|
+
output.push(comment);
|
|
114
|
+
}
|
|
115
|
+
output.push(line);
|
|
116
|
+
}
|
|
117
|
+
// The body opens with a blank line (the injected comment block above workspace_id), so the schema
|
|
118
|
+
// header sits on its own line at the very top with that blank line under it.
|
|
119
|
+
const body = output.join("\n").trimEnd();
|
|
120
|
+
// The "add more profiles" footer only makes sense when a profiles: block was actually written above it.
|
|
121
|
+
const footer = config.profiles && Object.keys(config.profiles).length > 0 ? `\n${PROFILES_FOOTER_COMMENT}` : "";
|
|
122
|
+
writeFileSync(filePath, `${SCHEMA_HEADER}${body}${footer}\n`);
|
|
123
|
+
}
|
|
124
|
+
// --- Defaults ----------------------------------------------------------------------------------------------------------------------------
|
|
125
|
+
// Appended after the last profile to hint at adding more (only when a profiles: block is present).
|
|
126
|
+
const PROFILES_FOOTER_COMMENT = " # Add more profiles here - or ask the agent inside the container to set one up for you.";
|
|
127
|
+
/**
|
|
128
|
+
* Create the minimal default ChambaYamlConfig: just the required workspace_id plus the security-relevant
|
|
129
|
+
* shadow_paths isolation default. Everything else (env, profiles, ports) is optional and documented
|
|
130
|
+
* in the schema the file's header points at, so we do not scaffold it into freshly generated files.
|
|
131
|
+
*/
|
|
132
|
+
export function buildDefaultChambaYaml(workspaceId) {
|
|
133
|
+
return {
|
|
134
|
+
workspace_id: workspaceId,
|
|
135
|
+
shadow_paths: [...DEFAULT_SHADOW_PATHS],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// --- Repair -------------------------------------------------------------------------------------------------------------------------------
|
|
139
|
+
/** Set of keys that ChambaYamlConfig allows (used to strip unknown fields). */
|
|
140
|
+
const KNOWN_KEYS = new Set(["workspace_id", "env", "shadow_paths", "profiles", "ports"]);
|
|
141
|
+
/**
|
|
142
|
+
* Attempt to repair an invalid chamba.yaml on disk.
|
|
143
|
+
* Strips unknown fields, fills missing required/optional fields from defaults,
|
|
144
|
+
* and rewrites with canonical formatting. Returns a result describing what happened.
|
|
145
|
+
*/
|
|
146
|
+
export function repairChambaYaml(dir) {
|
|
147
|
+
const filePath = join(dir, CHAMBA_YAML);
|
|
148
|
+
if (!existsSync(filePath))
|
|
149
|
+
return { repairedYaml: null };
|
|
150
|
+
try {
|
|
151
|
+
const raw = loadYaml(readFileSync(filePath, "utf8"));
|
|
152
|
+
if (typeof raw !== "object" || raw === null)
|
|
153
|
+
return { repairedYaml: null };
|
|
154
|
+
const obj = raw;
|
|
155
|
+
const fixes = [];
|
|
156
|
+
// Strip unknown fields
|
|
157
|
+
for (const key of Object.keys(obj)) {
|
|
158
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
159
|
+
delete obj[key];
|
|
160
|
+
fixes.push(`removed unknown field "${key}"`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Build defaults to fill from
|
|
164
|
+
const fallbackId = slugifyForWorkspaceId(basename(dir));
|
|
165
|
+
const defaults = buildDefaultChambaYaml(obj.workspace_id || fallbackId);
|
|
166
|
+
// Fill missing required fields
|
|
167
|
+
if (!("workspace_id" in obj)) {
|
|
168
|
+
obj.workspace_id = defaults.workspace_id;
|
|
169
|
+
fixes.push(`added missing workspace_id ("${defaults.workspace_id}")`);
|
|
170
|
+
}
|
|
171
|
+
// Restore the shadow_paths isolation default when absent (security-relevant). profiles/env are
|
|
172
|
+
// optional and intentionally not backfilled, so a deliberately minimal file is not re-bloated on repair.
|
|
173
|
+
if (!("shadow_paths" in obj)) {
|
|
174
|
+
obj.shadow_paths = defaults.shadow_paths;
|
|
175
|
+
fixes.push("added default shadow_paths");
|
|
176
|
+
}
|
|
177
|
+
if (fixes.length === 0)
|
|
178
|
+
return { repairedYaml: null };
|
|
179
|
+
// Validate the repaired object
|
|
180
|
+
const validate = getValidator();
|
|
181
|
+
if (!validate(obj)) {
|
|
182
|
+
return { repairedYaml: null, error: `${CHAMBA_YAML} could not be repaired: ${formatValidationErrors(validate.errors)}` };
|
|
183
|
+
}
|
|
184
|
+
const yaml = obj;
|
|
185
|
+
writeChambaYaml(dir, yaml);
|
|
186
|
+
return { repairedYaml: yaml, message: `Repaired ${CHAMBA_YAML}: ${fixes.join(", ")}` };
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
return { repairedYaml: null, error: `${CHAMBA_YAML} repair failed: ${err instanceof Error ? err.message : err}` };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/constants.ts - Canonical constants used across the chamba codebase
|
|
3
|
+
// =========================================================================================================================================
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
// Resolve the package root (repo root in dev, npm package root when installed).
|
|
7
|
+
// All compiled lib modules sit at dist/lib/*.js, so dirname x3 from this file lands at the package root.
|
|
8
|
+
export const PACKAGE_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
9
|
+
// ~/.chamba/ structure
|
|
10
|
+
export const CHAMBA_DIR = ".chamba";
|
|
11
|
+
export const WORKSPACES_DIR = "workspaces";
|
|
12
|
+
export const GLOBAL_DIR = "global"; // host-global state not tied to a workspace (the config file)
|
|
13
|
+
// Workspace cache subdirectories (under ~/.chamba/workspaces/<id>/)
|
|
14
|
+
export const AGENTS_DIR = "agents";
|
|
15
|
+
export const SHADOWS_DIR = "shadows";
|
|
16
|
+
export const PNPM_STORE_DIR = "pnpm-store";
|
|
17
|
+
export const WEBTERM_STATE_DIR = "webterm"; // the web interface's own state, bind-mounted into the container
|
|
18
|
+
// Filenames
|
|
19
|
+
export const CHAMBA_YAML = "chamba.yaml";
|
|
20
|
+
export const CHAMBA_SCHEMA_DIR = "schema";
|
|
21
|
+
export const CHAMBA_SCHEMA_FILE = "chamba.schema.json";
|
|
22
|
+
export const LOCK_FILE = ".lock";
|
|
23
|
+
export const GLOBAL_CONFIG_FILE = "config"; // ~/.chamba/global/config - key=value host-global settings
|
|
24
|
+
// The workspace shape this chamba writes, stamped into every .lock as `version=`. It is the shape's
|
|
25
|
+
// number, not the package version: it changes only when the on-disk layout does. Chamba's own numbering
|
|
26
|
+
// starts at 1 - every chamba workspace is created by this shape or a later one, so there is no older
|
|
27
|
+
// layout to recognize and nothing reads the marker yet. It exists so a future shape change can.
|
|
28
|
+
export const LOCK_VERSION = "1";
|
|
29
|
+
// Workspace ID constraints (must match schema/chamba.schema.json)
|
|
30
|
+
export const WORKSPACE_ID_MIN = 2;
|
|
31
|
+
export const WORKSPACE_ID_MAX = 48;
|
|
32
|
+
// Default shadow paths applied to new workspaces
|
|
33
|
+
export const DEFAULT_SHADOW_PATHS = ["node_modules", ".env*"];
|
|
34
|
+
// Container filesystem
|
|
35
|
+
export const CONTAINER_USER = "devuser";
|
|
36
|
+
export const CONTAINER_HOME = `/home/${CONTAINER_USER}`;
|
|
37
|
+
export const CONTAINER_WORKSPACE = "/workspace";
|
|
38
|
+
export const CONTAINER_STARTUP = `${CONTAINER_HOME}/startup.mjs`;
|
|
39
|
+
// pnpm's default global store path inside the container. Single source of truth for both the -v mount (pnpm-store.ts)
|
|
40
|
+
// and the store-dir env var (RUNTIME_ENV below) so the mount target and the env var can never drift apart.
|
|
41
|
+
export const CONTAINER_PNPM_STORE = `${CONTAINER_HOME}/.local/share/pnpm/store`;
|
|
42
|
+
// What the container runs as PID 1 so it stays up between sessions. It has to be a shell that traps TERM,
|
|
43
|
+
// not a bare `sleep infinity`: the kernel drops signals sent to PID 1 from inside its own PID namespace
|
|
44
|
+
// unless PID 1 installed a handler, and without a handler nothing in the container - including the web
|
|
45
|
+
// interface's "stop the container" button - can ever end it. `docker stop` from the host works either way,
|
|
46
|
+
// and gets cleaner with the trap: an immediate exit 0 instead of the 10s timeout and a SIGKILL.
|
|
47
|
+
export const CONTAINER_KEEP_ALIVE = ["bash", "-c", 'trap "exit 0" TERM INT; while :; do sleep 86400 & wait $!; done'];
|
|
48
|
+
// The status line script chamba installs - baked into the image, referenced from ~/.claude/settings.json.
|
|
49
|
+
// It renders nothing: it writes the per-session snapshot the web interface's status strip and the
|
|
50
|
+
// context-usage helper both read.
|
|
51
|
+
export const CLAUDE_STATUSLINE_PATH = "/usr/local/share/chamba/claude-statusline.sh";
|
|
52
|
+
// Docker container/image naming
|
|
53
|
+
export const CONTAINER_NAME_PREFIX = "chamba-";
|
|
54
|
+
// Docker label keys
|
|
55
|
+
export const LABEL_MANAGED = "chamba.managed";
|
|
56
|
+
export const LABEL_SHADOWS = "chamba.shadows";
|
|
57
|
+
export const LABEL_PROFILE = "chamba.profile";
|
|
58
|
+
export const LABEL_RUNTIME_ENV = "chamba.runtime-env";
|
|
59
|
+
export const LABEL_GIT_MODE = "chamba.git-mode";
|
|
60
|
+
export const LABEL_BUILD_HASH = "chamba.build-hash";
|
|
61
|
+
export const LABEL_PORTS = "chamba.ports";
|
|
62
|
+
export const LABEL_ENV = "chamba.env";
|
|
63
|
+
// Fallback active-profile name - used for the container label and .lock file when a workspace defines no profiles of its own.
|
|
64
|
+
export const DEFAULT_PROFILE = "default";
|
|
65
|
+
// Git guardrails modes (per-workspace, stored in .lock).
|
|
66
|
+
// Single source of truth lives in templates/runtime-constants.mjs so container-side
|
|
67
|
+
// scripts (startup.mjs, startup-git-mode.mjs) and TS code can both reference the
|
|
68
|
+
// same values without drift. We re-export here so internal code keeps importing
|
|
69
|
+
// from "./constants.js" as before.
|
|
70
|
+
import { GIT_MODE, GIT_WRAPPER_PATH, GIT_WRAPPER_SOURCE } from "../../templates/runtime-constants.mjs";
|
|
71
|
+
export { GIT_MODE, GIT_WRAPPER_PATH, GIT_WRAPPER_SOURCE };
|
|
72
|
+
export const GIT_MODES = Object.values(GIT_MODE);
|
|
73
|
+
// Runtime env vars injected into every container via docker run -e.
|
|
74
|
+
// Suppress Claude Code features that are inapplicable or disruptive inside the container, tune session behavior,
|
|
75
|
+
// and pin container-isolation safeguards (e.g. pnpm's store path).
|
|
76
|
+
export const RUNTIME_ENV = {
|
|
77
|
+
CLAUDE_AFK_TIMEOUT_MS: "2147483647", // Max 32-bit timer value (~24.8 days); avoids AFK timeouts in long unattended container sessions
|
|
78
|
+
CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY: "1", // Periodic feedback survey prompt is noise in ephemeral container sessions
|
|
79
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", // Suppress non-essential network calls (autoupdate checks, telemetry pings)
|
|
80
|
+
CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1", // Skip automatic addition of the official plugin marketplace on first run
|
|
81
|
+
CLAUDE_CODE_NO_FLICKER: "1", // Steadier redraws, which is the better terminal here and the one the web interface relays
|
|
82
|
+
DISABLE_AUTOUPDATER: "1", // In-process updater fails (root-owned prefix); startup.mjs handles updates
|
|
83
|
+
DISABLE_ERROR_REPORTING: "1", // Container errors include sandbox paths not useful to Anthropic
|
|
84
|
+
DISABLE_INSTALLATION_CHECKS: "1", // npm install is by design; native installer is not applicable
|
|
85
|
+
DISABLE_TELEMETRY: "1", // Container sessions should not phone home
|
|
86
|
+
DISABLE_UPGRADE_COMMAND: "1", // /upgrade is wrong path inside container; chamba manages CLI version
|
|
87
|
+
DO_NOT_TRACK: "1", // Universal opt-out honored by many CLIs/tools running in the container
|
|
88
|
+
// Force pnpm's store to the mounted host path so a per-project .pnpm-store never leaks to the host repo (see pnpm-store.ts).
|
|
89
|
+
// This removes pnpm's store-dir auto-detection, which could otherwise pick a different path before the mount takes effect.
|
|
90
|
+
// Both the pnpm-native and legacy npm-style keys are set so older pnpm versions honor it too.
|
|
91
|
+
npm_config_store_dir: CONTAINER_PNPM_STORE,
|
|
92
|
+
pnpm_config_store_dir: CONTAINER_PNPM_STORE,
|
|
93
|
+
};
|
|
94
|
+
// The agents chamba runs. Each value is the command itself, so the list doubles as what the webterm server
|
|
95
|
+
// may spawn, what the `webterm` launcher accepts, and what the browser may ask for per session. The default
|
|
96
|
+
// is what a session starts with until the user names another one.
|
|
97
|
+
export const WEB_AGENTS = ["claude", "opencode", "codex"];
|
|
98
|
+
export const DEFAULT_WEB_AGENT = "claude";
|
|
99
|
+
// Web agent interface (webterm). The server always binds this fixed container port; chamba publishes it
|
|
100
|
+
// loopback-only to a sticky per-workspace host port taken from web_range (host-global setting).
|
|
101
|
+
export const WEB_CONTAINER_PORT = 3899;
|
|
102
|
+
export const WEB_RANGE_DEFAULT = "3900-3999";
|
|
103
|
+
// Where the webterm server publishes the key its URL carries (`/?k=<key>`). A new key is minted every time
|
|
104
|
+
// that server starts and written here as soon as it has the port, so whoever prints the URL reads it back
|
|
105
|
+
// from this file rather than storing one: the container greeting, the `webterm` launcher, and chamba on the
|
|
106
|
+
// host before it probes /status. Container-side path - the host only ever reads it through `docker exec`.
|
|
107
|
+
export const WEB_KEY_FILE_PATH = "/tmp/webterm.key";
|
|
108
|
+
// Where the container sees this workspace's webterm state dir, bind-mounted from the cache dir above. The
|
|
109
|
+
// one thing in it is the agent file: which agent new web sessions start with, written by the server when
|
|
110
|
+
// `webterm <agent>` moves it and read back by the host at the next container start. It lives on the host
|
|
111
|
+
// side of a mount precisely so the choice outlives a container recreate.
|
|
112
|
+
export const CONTAINER_WEBTERM_STATE_DIR = `${CONTAINER_HOME}/.chamba-webterm`;
|
|
113
|
+
export const WEBTERM_AGENT_FILE = "agent";
|
|
114
|
+
export const WEBTERM_AGENT_FILE_PATH = `${CONTAINER_WEBTERM_STATE_DIR}/${WEBTERM_AGENT_FILE}`;
|
|
115
|
+
// The web pane's artifacts: one directory per conversation, holding the pages published to that
|
|
116
|
+
// conversation's pane and the feedback submitted on them. On the host it sits under the cache dir's
|
|
117
|
+
// agents/ dir, which is exactly what Advanced > Clear agent memory removes, so that menu item is the one
|
|
118
|
+
// thing that clears the pane. It is bind-mounted into the container, so the pages outlive the container
|
|
119
|
+
// and a resumed conversation finds its own history again. Nothing sweeps it by age.
|
|
120
|
+
export const WEBPANE_DIR = "webpane";
|
|
121
|
+
export const CONTAINER_WEBPANE_DIR = `${CONTAINER_HOME}/.webpane`;
|
|
122
|
+
// Resume marker: a host-written container file whose content is the full command that resumes the most
|
|
123
|
+
// recent conversation. Planted by dev.ts on every container create/start; consumed (rename-then-read,
|
|
124
|
+
// atomic) by exactly one webterm session, so the first session after a container start resumes and every
|
|
125
|
+
// later one starts fresh. Lives in the devuser home, not /tmp - the consumer executes the file's content,
|
|
126
|
+
// so it must not sit in a world-writable dir.
|
|
127
|
+
export const RESUME_MARKER_PATH = `${CONTAINER_HOME}/.chamba-resume-pending`;
|
|
128
|
+
// Per-agent command that reopens the most recent conversation. Pinned against the real CLIs by the
|
|
129
|
+
// drift test in tests/webterm.test.ts, which checks each flag/subcommand against the CLI's own help.
|
|
130
|
+
// Claude is not here: it is resumed by session id instead (resumeCommandFor), because `claude --continue`
|
|
131
|
+
// exits with "No conversation found to continue" when there is nothing to continue.
|
|
132
|
+
export const AGENT_RESUME_COMMAND = {
|
|
133
|
+
opencode: "opencode --continue",
|
|
134
|
+
codex: "codex resume --last",
|
|
135
|
+
};
|