chamba 0.3.1 → 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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -44
  3. package/bin/chamba.js +212 -0
  4. package/dist/commands/advanced.js +278 -0
  5. package/dist/commands/dev.js +619 -0
  6. package/dist/commands/doctor.js +29 -0
  7. package/dist/commands/menu.js +80 -0
  8. package/dist/commands/onboard.js +229 -0
  9. package/dist/commands/settings.js +349 -0
  10. package/dist/lib/agent-context.js +177 -0
  11. package/dist/lib/browser.js +40 -0
  12. package/dist/lib/chamba-yaml.js +191 -0
  13. package/dist/lib/constants.js +135 -0
  14. package/dist/lib/dockerfile-builder.js +267 -0
  15. package/dist/lib/env.js +78 -0
  16. package/dist/lib/global-config.js +66 -0
  17. package/dist/lib/pnpm-store.js +19 -0
  18. package/dist/lib/ports.js +210 -0
  19. package/dist/lib/safe-rm.js +26 -0
  20. package/dist/lib/sessions.js +34 -0
  21. package/dist/lib/shadows.js +174 -0
  22. package/dist/lib/webterm.js +490 -0
  23. package/dist/lib/workspace-identity.js +260 -0
  24. package/package.json +61 -23
  25. package/schema/chamba.schema.json +65 -0
  26. package/templates/.dockerignore +3 -0
  27. package/templates/Dockerfile +173 -0
  28. package/templates/claude-statusline.sh +120 -0
  29. package/templates/context/baseline.md +13 -0
  30. package/templates/context/context-usage.md +1 -0
  31. package/templates/context/git-mode-local.md +1 -0
  32. package/templates/context/git-mode-strict.md +1 -0
  33. package/templates/context/git-mode-unrestricted.md +1 -0
  34. package/templates/context/git-unavailable.md +1 -0
  35. package/templates/context/shadow-paths.md +3 -0
  36. package/templates/context-usage.sh +249 -0
  37. package/templates/git-readonly-wrapper.mjs +309 -0
  38. package/templates/npmrc +2 -0
  39. package/templates/pnpm-config.yaml +9 -0
  40. package/templates/runtime-constants.mjs +18 -0
  41. package/templates/skills/chamba-statusline/SKILL.md +79 -0
  42. package/templates/skills/context-usage/SKILL.md +53 -0
  43. package/templates/skills/web-pane/SKILL.md +62 -0
  44. package/templates/startup-git-mode.mjs +145 -0
  45. package/templates/startup.mjs +333 -0
  46. package/templates/webpane.sh +126 -0
  47. package/templates/webterm/README.md +157 -0
  48. package/templates/webterm/artifacts.js +583 -0
  49. package/templates/webterm/config.js +269 -0
  50. package/templates/webterm/context/claude.md +14 -0
  51. package/templates/webterm/conversation.js +248 -0
  52. package/templates/webterm/package-lock.json +884 -0
  53. package/templates/webterm/package.json +17 -0
  54. package/templates/webterm/pane.js +156 -0
  55. package/templates/webterm/proc.js +89 -0
  56. package/templates/webterm/public/app/alerts.js +472 -0
  57. package/templates/webterm/public/app/cards.js +123 -0
  58. package/templates/webterm/public/app/clipboard.js +229 -0
  59. package/templates/webterm/public/app/composer.js +226 -0
  60. package/templates/webterm/public/app/connection.js +342 -0
  61. package/templates/webterm/public/app/dictation.js +98 -0
  62. package/templates/webterm/public/app/dom.js +37 -0
  63. package/templates/webterm/public/app/drafts.js +244 -0
  64. package/templates/webterm/public/app/frames.js +166 -0
  65. package/templates/webterm/public/app/main.js +82 -0
  66. package/templates/webterm/public/app/new-session.js +188 -0
  67. package/templates/webterm/public/app/note.js +24 -0
  68. package/templates/webterm/public/app/pane-frame.js +166 -0
  69. package/templates/webterm/public/app/pane.js +353 -0
  70. package/templates/webterm/public/app/state.js +51 -0
  71. package/templates/webterm/public/app/status-strip.js +170 -0
  72. package/templates/webterm/public/app/tabs.js +475 -0
  73. package/templates/webterm/public/app/terminal.js +102 -0
  74. package/templates/webterm/public/app/theme.js +46 -0
  75. package/templates/webterm/public/favicon.svg +21 -0
  76. package/templates/webterm/public/index.html +105 -0
  77. package/templates/webterm/public/styles.css +1193 -0
  78. package/templates/webterm/server.js +1142 -0
  79. package/templates/webterm/sessions.js +515 -0
  80. package/templates/webterm/snapshot.js +135 -0
  81. package/templates/webterm.sh +167 -0
  82. package/dist/cli.js +0 -1582
  83. package/dist/server.js +0 -1831
  84. package/inject/annotate.js +0 -18
  85. package/skill/README.md +0 -12
  86. package/skill/SKILL.md +0 -93
  87. package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
  88. package/web/assets/index-B9DI4F1Z.js +0 -202
  89. package/web/assets/index-DK_n6CTo.css +0 -2
  90. package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
  91. package/web/index.html +0 -28
@@ -0,0 +1,26 @@
1
+ // =========================================================================================================================================
2
+ // src/lib/safe-rm.ts - Safe wrapper around rmSync
3
+ // All deletions in chamba go through safeRmSync. A biome lint rule (noRestrictedImports) bans
4
+ // importing rmSync from node:fs everywhere else, enforcing this as the single deletion point.
5
+ // =========================================================================================================================================
6
+ // biome-ignore lint/style/noRestrictedImports: safeRmSync is the only approved user of rmSync
7
+ import { rmSync } from "node:fs";
8
+ import { homedir, tmpdir } from "node:os";
9
+ import { basename, join, resolve, sep } from "node:path";
10
+ import { CHAMBA_DIR, CHAMBA_YAML, CONTAINER_NAME_PREFIX } from "./constants.js";
11
+ const CHAMBA_HOME = resolve(join(homedir(), CHAMBA_DIR));
12
+ const TEST_TMP_PREFIX = join(tmpdir(), `${CONTAINER_NAME_PREFIX}test-`);
13
+ /**
14
+ * Safe wrapper around rmSync. Throws if the path is outside a chamba-owned location:
15
+ * - ~/.chamba/ (workspace caches, agents, shadows, global config)
16
+ * - A file named chamba.yaml (workspace config file in any user workspace root)
17
+ * - <tmpdir>/chamba-test-* (test temp directories)
18
+ */
19
+ export function safeRmSync(path, options) {
20
+ const r = resolve(path);
21
+ const ok = r === CHAMBA_HOME || r.startsWith(CHAMBA_HOME + sep) || basename(r) === CHAMBA_YAML || r.startsWith(TEST_TMP_PREFIX);
22
+ if (!ok) {
23
+ throw new Error(`safeRmSync: refusing to delete '${r}' - must be under ~/.chamba/, named chamba.yaml, or a test temp dir`);
24
+ }
25
+ rmSync(r, options);
26
+ }
@@ -0,0 +1,34 @@
1
+ // =========================================================================================================================================
2
+ // src/lib/sessions.ts - Host-side detection of live interactive container sessions.
3
+ // dev.ts connects each session with `docker exec -it ... <container> bash --login`. The host-side exec
4
+ // client process is the proxy for one live session: it dies on clean exit, closed terminal, or kill, but
5
+ // survives a sleep-freeze. Orphaned in-container shells (host side gone, PTY left open by dockerd) have no
6
+ // host client and are correctly ignored - which is why counting on the host, not inside the container, is
7
+ // the reliable signal for "last session closed".
8
+ // =========================================================================================================================================
9
+ import { spawnSync } from "node:child_process";
10
+ // The interactive session shell, as argv. Single source of truth: both the connect command and the
11
+ // detector's match needle derive from this, so they cannot drift apart.
12
+ export const CONTAINER_LOGIN_SHELL = ["bash", "--login"];
13
+ // --- Connect -----------------------------------------------------------------------------------------------------------------------------
14
+ // Build the `docker exec` argv that opens an interactive session in `containerName` at `workdir`.
15
+ // dev.ts spawns exactly this; sessionMatchNeedle matches against it, so detection stays in lockstep.
16
+ export function loginShellExecArgs(workdir, containerName) {
17
+ return ["exec", "-it", "-w", workdir, containerName, ...CONTAINER_LOGIN_SHELL];
18
+ }
19
+ // --- Detection ---------------------------------------------------------------------------------------------------------------------------
20
+ // The substring identifying one container's session client in a host `pgrep -f` scan: the container name
21
+ // followed by the login shell args (e.g. "chamba-orot-io bash --login"). Specific enough to exclude other
22
+ // `docker exec <c> ...` shapes. Container ids are kebab-case (schema-constrained), so there are no regex
23
+ // metacharacters to escape.
24
+ export function sessionMatchNeedle(containerName) {
25
+ return `${containerName} ${CONTAINER_LOGIN_SHELL.join(" ")}`;
26
+ }
27
+ // Count live interactive sessions to a single container by scanning HOST processes for the exec client.
28
+ // `pgrep -f` (portable across macOS/BSD and Linux; `-c` is Linux-only) excludes its own PID. A missing or
29
+ // errored pgrep yields no stdout -> 0, preserving the old "errored -> 0" safety. At a clean exit the just-
30
+ // left client is already reaped before this runs, so the last exit reaches 0.
31
+ export function containerSessionCount(containerName) {
32
+ const r = spawnSync("pgrep", ["-f", sessionMatchNeedle(containerName)], { encoding: "utf8", stdio: "pipe" });
33
+ return (r.stdout ?? "").split("\n").filter(Boolean).length;
34
+ }
@@ -0,0 +1,174 @@
1
+ // =========================================================================================================================================
2
+ // src/lib/shadows.ts - Gitignore-style shadow path expansion and sync
3
+ // Expands patterns like "node_modules", ".env*" into concrete paths, then syncs shadow directories.
4
+ // =========================================================================================================================================
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
7
+ import { dirname, join, relative } from "node:path";
8
+ import fg from "fast-glob";
9
+ import { CONTAINER_WORKSPACE, SHADOWS_DIR } from "./constants.js";
10
+ import { safeRmSync } from "./safe-rm.js";
11
+ /**
12
+ * Expand gitignore-style patterns into concrete relative paths.
13
+ *
14
+ * Patterns without a directory separator are treated as recursive (prepended with **&#47;)
15
+ * following gitignore convention. Patterns with a / are matched relative to the workspace root.
16
+ * Matched directories are not recursed into (e.g. node_modules matches once, not its children).
17
+ *
18
+ * Paths that git tracks (or whose descendants git tracks) are dropped from the result and
19
+ * reported in skippedTracked. Shadowing tracked content is a no-op (agents can `git show` it)
20
+ * and breaks `git stash`/`pop` workflows.
21
+ */
22
+ export function expandShadowPatterns(patterns, workspaceRoot) {
23
+ if (patterns.length === 0)
24
+ return { paths: [], skippedTracked: [] };
25
+ // Convert gitignore-style patterns to fast-glob patterns
26
+ const globPatterns = patterns.map((p) => (p.includes("/") ? p : `**/${p}`));
27
+ // Build ignore list: skip .git and contents of any matched directory
28
+ const ignorePatterns = ["**/.git", ...globPatterns.map((p) => `${p}/**/*`)];
29
+ const results = fg.sync(globPatterns, {
30
+ cwd: workspaceRoot,
31
+ onlyFiles: false,
32
+ dot: true,
33
+ ignore: ignorePatterns,
34
+ });
35
+ const expanded = removeNestedPaths(results.sort());
36
+ const { kept, dropped } = filterGitTrackedPaths(expanded, workspaceRoot);
37
+ return { paths: kept, skippedTracked: dropped };
38
+ }
39
+ // --- Hit counting (for menu UX) ----------------------------------------------------------------------------------------------------------
40
+ /** Count how many paths a pattern would match in the workspace (post git-tracked filter). */
41
+ export function countPatternHits(pattern, workspaceRoot) {
42
+ return expandShadowPatterns([pattern], workspaceRoot).paths.length;
43
+ }
44
+ // --- Git-tracked filtering ---------------------------------------------------------------------------------------------------------------
45
+ /**
46
+ * Partition expanded shadow paths into those safe to shadow and those tracked by git.
47
+ * A path is dropped if it equals a tracked file, or (for directories) any tracked file
48
+ * lives anywhere beneath it. Returns the input unchanged when no .git is present.
49
+ */
50
+ function filterGitTrackedPaths(paths, workspaceRoot) {
51
+ if (paths.length === 0)
52
+ return { kept: [], dropped: [] };
53
+ // .git may be a file (worktrees) or a directory; existsSync handles both
54
+ if (!existsSync(join(workspaceRoot, ".git")))
55
+ return { kept: paths, dropped: [] };
56
+ // Pass shadow paths as pathspecs so git enumerates only tracked files within them.
57
+ // Output then scales with shadow scope (typically tiny), not the size of the repo index.
58
+ const result = spawnSync("git", ["-C", workspaceRoot, "ls-files", "-z", "--", ...paths], {
59
+ encoding: "utf8",
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ });
62
+ if (result.status !== 0)
63
+ return { kept: paths, dropped: [] };
64
+ const matched = result.stdout.split("\0").filter((s) => s.length > 0);
65
+ if (matched.length === 0)
66
+ return { kept: paths, dropped: [] };
67
+ const matchedSet = new Set(matched);
68
+ const kept = [];
69
+ const dropped = [];
70
+ for (const p of paths) {
71
+ const prefix = `${p}/`;
72
+ if (matchedSet.has(p) || matched.some((f) => f.startsWith(prefix))) {
73
+ dropped.push(p);
74
+ }
75
+ else {
76
+ kept.push(p);
77
+ }
78
+ }
79
+ return { kept, dropped };
80
+ }
81
+ // --- Shadow sync -------------------------------------------------------------------------------------------------------------------------
82
+ /**
83
+ * Ensures the shadows/ directory matches the given expanded paths.
84
+ * - Creates missing shadow entries (empty dir or empty file, matching source path type)
85
+ * - If `freshPaths` is provided, those entries are deleted and recreated (clean slate)
86
+ * - Removes shadow entries not in the expanded set
87
+ * - Cleans up empty parent directories
88
+ */
89
+ export function ensureShadowsInSync(workspaceDir, expandedPaths, workspaceRoot, freshPaths) {
90
+ const expected = new Set(expandedPaths);
91
+ const shadowsDir = join(workspaceDir, SHADOWS_DIR);
92
+ // Create shadows/ root if needed
93
+ mkdirSync(shadowsDir, { recursive: true });
94
+ // Remove stale entries
95
+ removeStaleEntries(shadowsDir, shadowsDir, expected);
96
+ // Create or refresh expected entries
97
+ for (const relPath of expected) {
98
+ const sourcePath = join(workspaceRoot, relPath);
99
+ const shadowPath = join(shadowsDir, relPath);
100
+ if (freshPaths?.has(relPath) && existsSync(shadowPath)) {
101
+ safeRmSync(shadowPath, { recursive: true, force: true });
102
+ }
103
+ if (!existsSync(shadowPath)) {
104
+ createShadowEntry(sourcePath, shadowPath);
105
+ }
106
+ }
107
+ }
108
+ // --- Mount args --------------------------------------------------------------------------------------------------------------------------
109
+ /** Build -v args for shadow mounts from expanded paths. */
110
+ export function buildShadowMountArgs(workspaceDir, expandedPaths) {
111
+ const args = [];
112
+ for (const relPath of expandedPaths) {
113
+ args.push("-v", `${join(workspaceDir, SHADOWS_DIR, relPath)}:${CONTAINER_WORKSPACE}/${relPath}`);
114
+ }
115
+ return args;
116
+ }
117
+ // --- Helpers -----------------------------------------------------------------------------------------------------------------------------
118
+ function createShadowEntry(sourcePath, shadowPath) {
119
+ if (existsSync(sourcePath) && !lstatSync(sourcePath).isDirectory()) {
120
+ mkdirSync(dirname(shadowPath), { recursive: true });
121
+ writeFileSync(shadowPath, "");
122
+ }
123
+ else {
124
+ mkdirSync(shadowPath, { recursive: true });
125
+ }
126
+ }
127
+ /**
128
+ * Walk the shadows/ tree and remove any entry whose relative path is not in the expected set.
129
+ * After removing leaf entries, prunes empty parent directories.
130
+ */
131
+ function removeStaleEntries(baseDir, currentDir, expected) {
132
+ if (!existsSync(currentDir))
133
+ return;
134
+ for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
135
+ const fullPath = join(currentDir, entry.name);
136
+ const rel = relative(baseDir, fullPath);
137
+ if (expected.has(rel)) {
138
+ // This path is expected - keep it
139
+ continue;
140
+ }
141
+ if (entry.isDirectory()) {
142
+ // Check if any expected path is nested under this directory
143
+ const hasExpectedChild = [...expected].some((p) => isDescendantOf(p, rel));
144
+ if (hasExpectedChild) {
145
+ // Recurse into the directory to clean stale children
146
+ removeStaleEntries(baseDir, fullPath, expected);
147
+ }
148
+ else {
149
+ // No expected paths under here - remove entirely
150
+ safeRmSync(fullPath, { recursive: true, force: true });
151
+ }
152
+ }
153
+ else {
154
+ // Stale file - remove
155
+ safeRmSync(fullPath, { force: true });
156
+ }
157
+ }
158
+ // Prune if this directory is now empty (and is not the shadows root)
159
+ if (currentDir !== baseDir && readdirSync(currentDir).length === 0) {
160
+ safeRmSync(currentDir, { recursive: true, force: true });
161
+ }
162
+ }
163
+ /**
164
+ * Drop paths that descend from another path in the input (keeps only the outermost path per subtree),
165
+ * so nested matches (e.g. node_modules inside a shadowed .next dir) don't become redundant bind mounts.
166
+ */
167
+ function removeNestedPaths(paths) {
168
+ const unique = [...new Set(paths)];
169
+ return unique.filter((p) => !unique.some((a) => a !== p && isDescendantOf(p, a)));
170
+ }
171
+ // Expects forward-slash paths (fast-glob output and shadows/ relative paths on POSIX).
172
+ function isDescendantOf(child, ancestor) {
173
+ return child.startsWith(`${ancestor}/`);
174
+ }