From 6f91524b4a39d14ef4311116dfc9c37af9fc5cb5 Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Mon, 31 Aug 2026 16:56:51 +0000 Subject: [PATCH 1/7] fix: correct --version, trust hashing, and install origins Read the CLI version from package.json instead of a stale 0.1.0 literal. Hash the executable trust surface with a key-sorted serializer so object key order cannot false-trigger a re-prompt. Unknown convenience features now raise BuckleError. Interactive TTYs print the lifecycle surface and prompt [y/N]; non-TTY still requires --trust. Refuse plaintext http:// install origins and stop swallowing MCP install failures. --- src/cli/install.ts | 8 +++- src/cli/render.ts | 72 +++++++++++++++++++-------------- src/features/catalog.ts | 21 ++++------ src/features/compile.ts | 7 +++- src/templates/resolver.ts | 10 +---- src/templates/trust.ts | 3 +- src/util/prompt.ts | 15 +++++++ src/util/stable-json.ts | 8 ++++ src/util/version.ts | 23 +++++++++++ test/unit/features.test.ts | 2 +- test/unit/install-parse.test.ts | 4 ++ test/unit/parse-rewrite.test.ts | 12 ++++-- test/unit/stable-json.test.ts | 24 +++++++++++ test/unit/version.test.ts | 15 +++++++ 14 files changed, 164 insertions(+), 60 deletions(-) create mode 100644 src/util/prompt.ts create mode 100644 src/util/stable-json.ts create mode 100644 src/util/version.ts create mode 100644 test/unit/stable-json.test.ts create mode 100644 test/unit/version.test.ts diff --git a/src/cli/install.ts b/src/cli/install.ts index 8ea5a28..aa34716 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -60,7 +60,13 @@ export function parseOrigin(input: string): ParsedOrigin { } else { url = `https://gitlab.com/${rest}.git`; } - } else if (urlPart.startsWith('https://') || urlPart.startsWith('http://') || urlPart.startsWith('git@') || urlPart.startsWith('ssh://')) { + } else if (urlPart.startsWith('http://')) { + throw new BuckleError( + ErrorCode.E_INSTALL_FAILED, + `refusing plaintext http origin: ${input}`, + 'use https://, git@, ssh://, gh:, gl:, or file://', + ); + } else if (urlPart.startsWith('https://') || urlPart.startsWith('git@') || urlPart.startsWith('ssh://')) { url = urlPart; } else if (urlPart.startsWith('file://')) { url = urlPart; diff --git a/src/cli/render.ts b/src/cli/render.ts index 55ff2d6..751aee5 100644 --- a/src/cli/render.ts +++ b/src/cli/render.ts @@ -10,6 +10,7 @@ import type { Template } from '../templates/schema.js'; import { checkTrust, hookSurfaceHash, recordTrust } from '../templates/trust.js'; import { applyPlan, plan, renderDiff, summarizePlan, type RenderPlan } from '../generators/writer.js'; import { BuckleError, ErrorCode } from '../util/errors.js'; +import { promptYesNo } from '../util/prompt.js'; import type { CliContext } from './context.js'; export interface RenderArgs { @@ -56,25 +57,30 @@ export async function renderTemplate(ctx: CliContext, args: RenderArgs): Promise if (!trusted) { const decision = await checkTrust(hash, surface); if (!decision.trusted) { - // For non-interactive contexts, surface a clear error. + const reason = decision.changed + ? `template "${args.templateName}" lifecycle hooks have changed since you last trusted it` + : `template "${args.templateName}" has unverified lifecycle hooks`; + // Non-interactive / JSON: never silently trust. if (process.stdin.isTTY !== true || ctx.flags.json) { throw new BuckleError( ErrorCode.E_HASH_MISMATCH, - decision.changed - ? `template "${args.templateName}" lifecycle hooks have changed since you last trusted it` - : `template "${args.templateName}" has unverified lifecycle hooks; review them before running`, - 're-run with --trust after reviewing the template, or run interactively to be prompted', + reason, + 'review with `buckle view`, then re-run with --trust', + ); + } + ctx.logger.warn(reason); + ctx.logger.info('executable surface (lifecycle, mounts, runArgs, features):'); + for (const line of formatTrustSurface(resolved.merged)) { + ctx.logger.info(` ${line}`); + } + const accepted = await promptYesNo('Trust this template and continue? [y/N] '); + if (!accepted) { + throw new BuckleError( + ErrorCode.E_USER_ABORT, + 'aborted: template not trusted', + 'pass --trust on the next run after reviewing with `buckle view`', ); } - // Interactive: print the surface and ask. - ctx.logger.warn( - `Template "${args.templateName}" wants to run lifecycle commands on first use. Run "buckle view ${args.templateName}" to inspect, or pass --trust to accept.`, - ); - throw new BuckleError( - ErrorCode.E_USER_ABORT, - 'aborted: template not trusted', - 'pass --trust on the next run to accept the lifecycle commands', - ); } trusted = true; } @@ -85,13 +91,11 @@ export async function renderTemplate(ctx: CliContext, args: RenderArgs): Promise await recordTrust(hash, surface); } - // Pass --isolate through; stripping of home mounts happens after feature expansion inside - // buildDevcontainer (so both template-declared and feature-injected mounts are removed). const projectName = basename(ctx.cwd); const p = await plan(resolved.merged, { cwd: ctx.cwd, projectName, - ...(ctx.flags.isolate ? { isolate: true } : {}), + isolate: ctx.flags.isolate !== false, }); const summary = summarizePlan(p); for (const s of summary) { @@ -127,9 +131,7 @@ export async function renderTemplate(ctx: CliContext, args: RenderArgs): Promise return { template: resolved.merged, hash, plan: p, written: false, trusted }; } if (!args.yes && process.stdin.isTTY === true && !ctx.flags.json) { - // Best-effort interactive confirmation. In headless contexts (--yes / non-TTY), - // we proceed without prompting. - const confirmed = await confirm(`Apply changes to .devcontainer/ in ${ctx.cwd}? [y/N] `); + const confirmed = await promptYesNo(`Apply changes to .devcontainer/ in ${ctx.cwd}? [y/N] `); if (!confirmed) { throw new BuckleError(ErrorCode.E_USER_ABORT, 'aborted by user'); } @@ -143,14 +145,24 @@ export async function renderTemplate(ctx: CliContext, args: RenderArgs): Promise return { template: resolved.merged, hash, plan: p, written, trusted }; } -async function confirm(prompt: string): Promise { - return new Promise((resolveP) => { - process.stderr.write(prompt); - const onData = (chunk: Buffer) => { - const ans = chunk.toString().trim().toLowerCase(); - process.stdin.off('data', onData); - resolveP(ans === 'y' || ans === 'yes'); - }; - process.stdin.once('data', onData); - }); +function formatTrustSurface(t: Template): string[] { + const lines: string[] = []; + const hooks = t.lifecycle ?? {}; + for (const [name, steps] of Object.entries(hooks)) { + if (!steps || steps.length === 0) continue; + const cmds = steps.map((s) => (typeof s === 'string' ? s : s.command)); + lines.push(`${name}: ${cmds.join(' && ')}`); + } + if (t.mounts && t.mounts.length > 0) { + for (const m of t.mounts) { + lines.push(`mount: ${m.source} → ${m.target} (${m.type ?? 'bind'})`); + } + } + if (t.runArgs && t.runArgs.length > 0) lines.push(`runArgs: ${t.runArgs.join(' ')}`); + if (t.features && t.features.length > 0) { + const names = t.features.map((f) => (typeof f === 'string' ? f : f[0])); + lines.push(`features: ${names.join(', ')}`); + } + if (lines.length === 0) lines.push('(no lifecycle, mounts, runArgs, or features)'); + return lines; } diff --git a/src/features/catalog.ts b/src/features/catalog.ts index db41256..59bf260 100644 --- a/src/features/catalog.ts +++ b/src/features/catalog.ts @@ -1,4 +1,6 @@ +import { deepMerge } from '../templates/resolver.js'; import type { Template } from '../templates/schema.js'; +import { BuckleError, ErrorCode } from '../util/errors.js'; /** * The Buckle convenience-feature catalog. @@ -19,16 +21,7 @@ const NATIVE = (id: string, opts: Record = {}): FeaturePatch => }); function deepMergePatch(a: FeaturePatch, b: FeaturePatch): FeaturePatch { - // local minimal merge, mirrors resolver semantics for arrays-append/objects-merge. - const out: Record = { ...a }; - for (const [k, v] of Object.entries(b)) { - const existing = out[k]; - if (Array.isArray(existing) && Array.isArray(v)) out[k] = [...existing, ...v]; - else if (existing && typeof existing === 'object' && v && typeof v === 'object' && !Array.isArray(v)) - out[k] = deepMergePatch(existing as FeaturePatch, v as FeaturePatch); - else out[k] = v; - } - return out as FeaturePatch; + return deepMerge(a, b) as FeaturePatch; } const CATALOG: Record = { @@ -143,7 +136,7 @@ function mcpFeature(name: string): FeaturePatch { const pkg = name.startsWith('@') ? name : `@modelcontextprotocol/server-${name}`; return { lifecycle: { - postCreate: [`npm install -g ${pkg} || true`], + postCreate: [`npm install -g ${pkg}`], }, }; } @@ -200,7 +193,7 @@ export function compileFeature(spec: FeatureSpec): FeaturePatch { } const fn = CATALOG[spec.name]; if (!fn) { - throw new Error(`unknown feature: ${spec.name}`); + throw new BuckleError(ErrorCode.E_TEMPLATE_INVALID, `unknown feature: ${spec.name}`); } return fn(spec.arg); } @@ -211,8 +204,8 @@ export function listFeatures(): { name: string; description: string }[] { { name: 'dind', description: 'Docker in docker (privileged dockerd)' }, { name: 'gh', description: 'GitHub CLI' }, { name: 'git-config', description: 'Bind-mount host ~/.gitconfig read-only' }, - { name: 'claude-code', description: 'Install Claude Code CLI; mount ~/.claude (pairs excellently with grok)' }, - { name: 'grok', description: 'Install Grok Build (xAI); mount ~/.grok (pairs excellently with claude-code)' }, + { name: 'claude-code', description: 'Install Claude Code CLI; isolated per workspace by default (pairs with grok)' }, + { name: 'grok', description: 'Install Grok Build (xAI); isolated per workspace by default (pairs with claude-code)' }, { name: 'grok-build', description: 'Alias for grok' }, { name: 'mcp:', description: 'Install an MCP server (filesystem, github, …)' }, { name: 'aws', description: 'AWS CLI v2' }, diff --git a/src/features/compile.ts b/src/features/compile.ts index db18406..5070d41 100644 --- a/src/features/compile.ts +++ b/src/features/compile.ts @@ -1,5 +1,6 @@ import { deepMerge } from '../templates/resolver.js'; import type { Template } from '../templates/schema.js'; +import { BuckleError, ErrorCode } from '../util/errors.js'; import { compileFeature, isKnownFeature, parseFeatureSpec } from './catalog.js'; /** @@ -18,7 +19,11 @@ export function applyFeatures(t: Template): Template { for (const entry of featuresIn) { const raw = typeof entry === 'string' ? entry : entry[0]; if (!isKnownFeature(raw)) { - throw new Error(`unknown feature in template: "${raw}"`); + throw new BuckleError( + ErrorCode.E_TEMPLATE_INVALID, + `unknown feature in template: "${raw}"`, + 'run `buckle list` and `buckle doctor` for the convenience catalog, or pass a native ghcr.io/ feature id', + ); } const spec = parseFeatureSpec(raw); if (typeof entry !== 'string' && entry.length === 2) { diff --git a/src/templates/resolver.ts b/src/templates/resolver.ts index 72d24ae..417ead8 100644 --- a/src/templates/resolver.ts +++ b/src/templates/resolver.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { BuckleError, ErrorCode } from '../util/errors.js'; +import { stableStringify } from '../util/stable-json.js'; import { findTemplate, type CatalogOptions, type TemplateRecord } from './loader.js'; import { TemplateSchema, validateSourceMutex, type Template } from './schema.js'; @@ -119,15 +120,6 @@ function stripExtends(t: Template): Template { return rest as Template; } -/** Stable JSON serializer for hashing (sorted keys, no formatting). */ -function stableStringify(value: unknown): string { - if (value === null || typeof value !== 'object') return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; - const obj = value as Record; - const keys = Object.keys(obj).sort(); - return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`; -} - export function templateHash(t: Template): string { return createHash('sha256').update(stableStringify(t)).digest('hex'); } diff --git a/src/templates/trust.ts b/src/templates/trust.ts index ac92ab3..49aca8a 100644 --- a/src/templates/trust.ts +++ b/src/templates/trust.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { exists, readTextOrUndefined, writeTextAtomic } from '../util/fs.js'; import { bucklePaths } from '../util/paths.js'; +import { stableStringify } from '../util/stable-json.js'; import type { Template } from './schema.js'; export interface TrustEntry { @@ -44,7 +45,7 @@ export function hookSurfaceHash(t: Template): string { nativeFeatures: t.nativeFeatures ?? null, customizations: t.customizations ?? null, }; - return createHash('sha256').update(JSON.stringify(surface)).digest('hex'); + return createHash('sha256').update(stableStringify(surface)).digest('hex'); } export interface TrustDecision { diff --git a/src/util/prompt.ts b/src/util/prompt.ts new file mode 100644 index 0000000..7a50b37 --- /dev/null +++ b/src/util/prompt.ts @@ -0,0 +1,15 @@ +import * as readline from 'node:readline'; + +/** Ask a yes/no question on stderr so --json stdout stays clean. Defaults to no. */ +export async function promptYesNo(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + try { + const ans = await new Promise((resolve) => { + rl.question(question, resolve); + }); + const normalized = ans.trim().toLowerCase(); + return normalized === 'y' || normalized === 'yes'; + } finally { + rl.close(); + } +} diff --git a/src/util/stable-json.ts b/src/util/stable-json.ts new file mode 100644 index 0000000..c3e23f5 --- /dev/null +++ b/src/util/stable-json.ts @@ -0,0 +1,8 @@ +/** Canonical JSON for hashing: sorted object keys, no whitespace. */ +export function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + const obj = value as Record; + const keys = Object.keys(obj).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`; +} diff --git a/src/util/version.ts b/src/util/version.ts new file mode 100644 index 0000000..85bd4ca --- /dev/null +++ b/src/util/version.ts @@ -0,0 +1,23 @@ +import { createRequire } from 'node:module'; + +/** + * Resolve the published package version without baking it into source. + * + * `import.meta.url` points at `src/util/version.ts` under vitest and at + * `dist/index.js` after tsup bundles; we probe both relative layouts and + * require the name to match so a stray nearby package.json cannot win. + */ +export function packageVersion(): string { + const req = createRequire(import.meta.url); + for (const rel of ['../../package.json', '../package.json', './package.json']) { + try { + const pkg = req(rel) as { name?: string; version?: string }; + if (pkg.name === 'buckle-cli' && typeof pkg.version === 'string' && pkg.version.length > 0) { + return pkg.version; + } + } catch { + continue; + } + } + return '0.0.0-dev'; +} diff --git a/test/unit/features.test.ts b/test/unit/features.test.ts index 347c226..8fc3bb5 100644 --- a/test/unit/features.test.ts +++ b/test/unit/features.test.ts @@ -114,7 +114,7 @@ describe('applyFeatures', () => { it('throws on unknown feature', () => { const t: Template = { version: '0.1.0', image: 'foo:1', features: ['definitely-not-real'] }; - expect(() => applyFeatures(t)).toThrow(); + expect(() => applyFeatures(t)).toThrow(/unknown feature/); }); it('returns input unchanged when no features', () => { diff --git a/test/unit/install-parse.test.ts b/test/unit/install-parse.test.ts index 4f92725..14d1294 100644 --- a/test/unit/install-parse.test.ts +++ b/test/unit/install-parse.test.ts @@ -40,6 +40,10 @@ describe('parseOrigin', () => { expect(() => parseOrigin('something-bad')).toThrow(); }); + it('rejects plaintext http origins', () => { + expect(() => parseOrigin('http://example.com/x.git')).toThrow(/plaintext http/); + }); + it('hashKey is stable across calls and varies with ref', () => { const a = parseOrigin('gh:foo/bar#v1').hashKey; const b = parseOrigin('gh:foo/bar#v1').hashKey; diff --git a/test/unit/parse-rewrite.test.ts b/test/unit/parse-rewrite.test.ts index bd32ab1..4a2ec3b 100644 --- a/test/unit/parse-rewrite.test.ts +++ b/test/unit/parse-rewrite.test.ts @@ -36,14 +36,20 @@ describe('dispatch — template rewrite path', () => { }); it('handles `--version`', async () => { - // commander's --version exits the process by default; we run it via dispatch and expect - // it to not throw and to mark non-tui. + // commander's --version writes the package version then exits; swallow the exit. + const chunks: string[] = []; + vi.spyOn(process.stdout, 'write').mockImplementation(((s: string | Uint8Array) => { + chunks.push(typeof s === 'string' ? s : String(s)); + return true; + }) as typeof process.stdout.write); const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((_code?: number) => { - // swallow return undefined as never; }) as never); const r = await dispatch(['node', 'buckle', '--version']); expect(r.tui).toBe(false); + const printed = chunks.join(''); + expect(printed).toMatch(/^\d+\.\d+\.\d+/); + expect(printed).not.toMatch(/^0\.1\.0\b/); exitSpy.mockRestore(); }); }); diff --git a/test/unit/stable-json.test.ts b/test/unit/stable-json.test.ts new file mode 100644 index 0000000..f48de02 --- /dev/null +++ b/test/unit/stable-json.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { hookSurfaceHash } from '../../src/templates/trust.js'; +import { stableStringify } from '../../src/util/stable-json.js'; + +describe('stableStringify', () => { + it('sorts object keys', () => { + expect(stableStringify({ b: 1, a: 2 })).toBe('{"a":2,"b":1}'); + }); +}); + +describe('hookSurfaceHash', () => { + it('is independent of object key insertion order', () => { + const a = hookSurfaceHash({ + mounts: [{ source: '/a', target: '/b', type: 'bind' }], + runArgs: ['--init'], + } as never); + const b = hookSurfaceHash({ + runArgs: ['--init'], + mounts: [{ source: '/a', target: '/b', type: 'bind' }], + } as never); + expect(a).toBe(b); + }); +}); diff --git a/test/unit/version.test.ts b/test/unit/version.test.ts new file mode 100644 index 0000000..c4ed695 --- /dev/null +++ b/test/unit/version.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { packageVersion } from '../../src/util/version.js'; + +describe('packageVersion', () => { + it('matches package.json', () => { + const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }; + expect(packageVersion()).toBe(pkg.version); + }); +}); From 806b332c24695f185f7e5fbbbfe6f44c29615abb Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Mon, 31 Aug 2026 16:57:01 +0000 Subject: [PATCH 2/7] feat!: isolate AI skills and config per workspace by default Remap ~/.claude and ~/.grok onto $XDG_DATA_HOME/buckle/workspaces/-/{claude,grok} so agent skills, versions, and config do not leak across containers. Host ~/.gitconfig stays shared (identity). Opt out with --share-home, --no-isolate, or isolate: false in ~/.config/buckle/config.yaml. Load that config file for editor, defaultTemplate, and isolate. --version now tracks package.json. Empty-directory autodetect leads with ai-native. --- src/cli/commands/doctor.ts | 17 ++++++++ src/cli/commands/edit.ts | 2 +- src/cli/commands/list.ts | 18 +++++++-- src/cli/commands/new.ts | 2 +- src/cli/commands/view.ts | 3 +- src/cli/context.ts | 26 +++++++++++-- src/cli/parse.ts | 17 ++++++-- src/docker/devcontainer-cli.ts | 5 ++- src/generators/compose.ts | 2 +- src/generators/devcontainer.ts | 46 ++++++---------------- src/generators/writer.ts | 14 +++++-- src/templates/ai.ts | 8 ++++ src/templates/autodetect.ts | 4 +- src/templates/isolate.ts | 67 ++++++++++++++++++++++++++++++++ src/util/config.ts | 62 +++++++++++++++++++++++++++++ test/unit/autodetect.test.ts | 4 +- test/unit/config.test.ts | 53 +++++++++++++++++++++++++ test/unit/isolate.test.ts | 71 ++++++++++++++++++++++++++++++++++ vitest.config.ts | 6 +++ 19 files changed, 372 insertions(+), 55 deletions(-) create mode 100644 src/templates/ai.ts create mode 100644 src/templates/isolate.ts create mode 100644 src/util/config.ts create mode 100644 test/unit/config.test.ts create mode 100644 test/unit/isolate.test.ts diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 4be3275..260bda4 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -5,6 +5,8 @@ import { execa } from 'execa'; import { DockerCli } from '../../docker/inspect.js'; import { hasDevcontainerCli } from '../../docker/devcontainer-cli.js'; +import { DUAL_AGENT_TEMPLATES } from '../../templates/ai.js'; +import { workspaceStateDir } from '../../templates/isolate.js'; import { exists } from '../../util/fs.js'; import { bucklePaths } from '../../util/paths.js'; import { styles } from '../../util/log.js'; @@ -93,6 +95,21 @@ export async function runDoctor(ctx: CliContext): Promise { checks.push({ name: 'docker.buildx', status: 'warn', message: 'docker buildx not available' }); } + const isolateOn = ctx.flags.isolate !== false; + checks.push({ + name: 'ai.isolate', + status: 'pass', + message: isolateOn + ? `per-workspace agent state (${workspaceStateDir(ctx.cwd)}; --share-home to bind host ~/.claude and ~/.grok)` + : 'sharing host ~/.claude and ~/.grok (--share-home)', + }); + + checks.push({ + name: 'ai.templates', + status: 'pass', + message: `dual-agent ready: ${DUAL_AGENT_TEMPLATES.join(', ')} (Claude Code + Grok Build)`, + }); + const overall = checks.some((c) => c.status === 'fail') ? 'broken' : checks.some((c) => c.status === 'warn') diff --git a/src/cli/commands/edit.ts b/src/cli/commands/edit.ts index 10f0b3f..036cb4d 100644 --- a/src/cli/commands/edit.ts +++ b/src/cli/commands/edit.ts @@ -14,7 +14,7 @@ export async function runEdit(ctx: CliContext, args: { template: string }): Prom `run "buckle view ${args.template}" to inspect, or "buckle new ${args.template} --extend ${args.template}" to create an editable copy`, ); } - const editor = process.env['VISUAL'] ?? process.env['EDITOR'] ?? 'vi'; + const editor = ctx.config.editor ?? process.env['VISUAL'] ?? process.env['EDITOR'] ?? 'vi'; return new Promise((resolveP) => { const proc = spawn(editor, [rec.path], { stdio: 'inherit' }); proc.on('exit', (code) => resolveP(code ?? 0)); diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 36e3910..0c9401c 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -1,4 +1,5 @@ /** `buckle list` — built-in + user + installed templates. */ +import { isDualAgentTemplate } from '../../templates/ai.js'; import { listCatalog } from '../../templates/loader.js'; import { styles } from '../../util/log.js'; import type { CliContext } from '../context.js'; @@ -9,7 +10,17 @@ export async function runList(ctx: CliContext): Promise { const filtered = ctx.flags.installedOnly ? items.filter((i) => i.origin !== 'builtin') : items; if (ctx.flags.json) { - emit(jsonOk({ templates: filtered }, ctx.cwd)); + emit( + jsonOk( + { + templates: filtered.map((t) => ({ + ...t, + dualAgent: isDualAgentTemplate(t.name), + })), + }, + ctx.cwd, + ), + ); return 0; } if (filtered.length === 0) { @@ -18,13 +29,14 @@ export async function runList(ctx: CliContext): Promise { } const widest = filtered.reduce((m, i) => Math.max(m, i.name.length), 0); for (const item of filtered) { - const tag = + const originTag = item.origin === 'builtin' ? styles.gray('built-in') : item.origin === 'user' ? styles.cyan('user') : styles.green(`installed${item.installOrigin ? ` (${item.installOrigin.slice(0, 8)})` : ''}`); - process.stdout.write(` ${item.name.padEnd(widest)} ${tag} ${item.description ?? ''}\n`); + const dual = isDualAgentTemplate(item.name) ? ` ${styles.cyan('dual-agent')}` : ''; + process.stdout.write(` ${item.name.padEnd(widest)} ${originTag}${dual} ${item.description ?? ''}\n`); } return 0; } diff --git a/src/cli/commands/new.ts b/src/cli/commands/new.ts index 4d22ef2..c546c66 100644 --- a/src/cli/commands/new.ts +++ b/src/cli/commands/new.ts @@ -46,7 +46,7 @@ export async function runNew(ctx: CliContext, args: NewArgs): Promise { }, }; const text = - `# Buckle template: ${args.name}\n# See https://github.com/buckle-dev/buckle for the schema.\n\n` + + `# Buckle template: ${args.name}\n# See https://github.com/entropyvortex/buckle for the schema.\n\n` + yamlStringify(starter); await writeTextAtomic(dest, text); if (ctx.flags.json) emit(jsonOk({ name: args.name, path: dest }, ctx.cwd)); diff --git a/src/cli/commands/view.ts b/src/cli/commands/view.ts index b8a9231..674fa8a 100644 --- a/src/cli/commands/view.ts +++ b/src/cli/commands/view.ts @@ -17,7 +17,8 @@ export async function runView(ctx: CliContext, args: ViewArgs): Promise const resolved = await resolveTemplate(args.template, { overlay }); // isolate is passed to build so that feature-expanded mounts (and env) are also filtered. const dc = buildDevcontainer(resolved.merged, 'view', { - ...(ctx.flags.isolate ? { isolate: true } : {}), + isolate: ctx.flags.isolate !== false, + cwd: ctx.cwd, }); if (ctx.flags.json) { emit(jsonOk({ template: args.template, devcontainer: dc }, ctx.cwd)); diff --git a/src/cli/context.ts b/src/cli/context.ts index b7c0c3e..f680e78 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -1,5 +1,6 @@ import { resolve as pathResolve } from 'node:path'; +import { loadConfigSync, type BuckleConfig } from '../util/config.js'; import { makeLogger, type Logger } from '../util/log.js'; export interface CliFlags { @@ -15,19 +16,38 @@ export interface CliFlags { installedOnly?: boolean; force?: boolean; preview?: boolean; + /** + * When true (the default), AI agent skills/config live in a per-workspace + * directory instead of the host ~/.claude and ~/.grok. + * `--share-home` / `--no-isolate` sets this to false. + */ isolate?: boolean; + /** Bind-mount host ~/.claude, ~/.grok, ~/.gitconfig. Inverse of isolate. */ + shareHome?: boolean; } export interface CliContext { cwd: string; flags: CliFlags; logger: Logger; + config: BuckleConfig; +} + +function resolveIsolate(flags: CliFlags, config: BuckleConfig): boolean { + if (flags.shareHome === true) return false; + if (flags.isolate === false) return false; + if (flags.isolate === true) return true; + if (config.isolate === false) return false; + return true; } export function makeContext(flags: CliFlags, cwd?: string): CliContext { + const config = loadConfigSync(); + const isolate = resolveIsolate(flags, config); + const merged: CliFlags = { ...flags, isolate, shareHome: !isolate }; const logger = makeLogger({ - ...(flags.json !== undefined ? { json: flags.json } : {}), - ...(flags.verbose !== undefined ? { verbose: flags.verbose } : {}), + ...(merged.json !== undefined ? { json: merged.json } : {}), + ...(merged.verbose !== undefined ? { verbose: merged.verbose } : {}), }); - return { cwd: pathResolve(cwd ?? process.cwd()), flags, logger }; + return { cwd: pathResolve(cwd ?? process.cwd()), flags: merged, logger, config }; } diff --git a/src/cli/parse.ts b/src/cli/parse.ts index d82d714..8103a9a 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -39,6 +39,7 @@ import { join } from 'node:path'; import { makeContext, type CliFlags } from './context.js'; import { templateExists } from '../templates/loader.js'; import { exists } from '../util/fs.js'; +import { packageVersion } from '../util/version.js'; export interface RunResult { exitCode: number; @@ -67,7 +68,15 @@ function attachGlobals(cmd: Command): Command { .option('--installed-only', 'list installed (non-built-in) templates only') .option('--force', 'force overwrite on install') .option('--preview, --dry-run', 'show what would be written without changing the filesystem') - .option('--isolate', 'skip bind-mounts + related containerEnv for host AI/home dirs (~/.claude, ~/.grok, ~/.gitconfig) — escape hatch for first-creation friction on some macOS arm64 setups'); + .option( + '--isolate', + 'keep AI agent skills/config per workspace (default). Each container gets its own ~/.claude and ~/.grok under $XDG_DATA_HOME/buckle/workspaces/', + ) + .option('--no-isolate', 'alias for --share-home') + .option( + '--share-home', + 'bind-mount host ~/.claude, ~/.grok, and ~/.gitconfig into the container (disables per-workspace isolation)', + ); } function readGlobals(cmd: Command): CliFlags { @@ -84,7 +93,9 @@ function readGlobals(cmd: Command): CliFlags { installedOnly: Boolean(o['installedOnly']), force: Boolean(o['force']), preview: Boolean(o['preview']), - isolate: Boolean(o['isolate']), + shareHome: Boolean(o['shareHome']), + // Isolation is the default. --share-home or --no-isolate opts out. + isolate: Boolean(o['shareHome']) ? false : o['isolate'] !== false, }; if (typeof o['user'] === 'string') out.user = o['user']; return out; @@ -163,7 +174,7 @@ async function runProgram(argv: string[]): Promise { program .name('buckle') .description('One verb for devcontainers — generate, build, up, and bash with user-wide templates.') - .version('0.1.0', '-V, --version', 'print the buckle version'); + .version(packageVersion(), '-V, --version', 'print the buckle version'); attachGlobals(program); diff --git a/src/docker/devcontainer-cli.ts b/src/docker/devcontainer-cli.ts index 02697f6..ef596aa 100644 --- a/src/docker/devcontainer-cli.ts +++ b/src/docker/devcontainer-cli.ts @@ -92,8 +92,9 @@ export async function up(opts: UpOptions): Promise<{ containerId: string }> { hint += '\n\nThis is usually caused by a bad cached features image layer.\n' + 'Try: buckle up