From 0d9d41b1a800c4c868c8469c529470454fbf6bd1 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:17:04 +0530 Subject: [PATCH 1/2] fix: default AI context exports to Base cache --- .ai-context/COMMANDS.md | 5 +- .../basectl/subcommands/export_context.sh | 4 + .../basectl/tests/export-context.bats | 2 + cli/python/base_export_context/engine.py | 8 +- .../base_export_context/tests/test_engine.py | 88 ++++++++++++++++++- docs/command-reference.md | 8 ++ 6 files changed, 111 insertions(+), 4 deletions(-) diff --git a/.ai-context/COMMANDS.md b/.ai-context/COMMANDS.md index 09a10e4e..a1b5dc0f 100644 --- a/.ai-context/COMMANDS.md +++ b/.ai-context/COMMANDS.md @@ -47,7 +47,10 @@ the v1.x compatibility window; new automation should use `--debug-wrapper`. - `basectl demo [project]` - run a declared interactive demo script. - `basectl run [project] ` - run a declared command for the positional, explicit, or nearest project. - `basectl export-context [project]` - export `.ai-context/` as a Markdown or - Zip bundle for manual upload or copy/paste into AI tools. + Zip bundle for manual upload or copy/paste into AI tools. Defaults to + `$BASE_CACHE_DIR/base/exports/-ai-context.md` (or `.zip`); + `--output` explicitly selects another destination, including project directories. + `--print` and `--list-files` do not write a bundle. - `basectl trust status [project]` - inspect one project's manifest command trust or all discovered command-bearing projects. - Generated onboarding trust commands include the canonical workspace and diff --git a/cli/bash/commands/basectl/subcommands/export_context.sh b/cli/bash/commands/basectl/subcommands/export_context.sh index 6753169a..17840a10 100644 --- a/cli/bash/commands/basectl/subcommands/export_context.sh +++ b/cli/bash/commands/basectl/subcommands/export_context.sh @@ -27,6 +27,10 @@ Options: Export a Base-managed project's .ai-context directory for manual upload or copy/paste into AI tools. + +Without --output, bundles are written under base/exports/ in the Base cache +root (BASE_CACHE_DIR), using -ai-context.md or -ai-context.zip. +Use --output to choose another destination, including a project directory. EOF } diff --git a/cli/bash/commands/basectl/tests/export-context.bats b/cli/bash/commands/basectl/tests/export-context.bats index 8ec60545..7719cbf0 100644 --- a/cli/bash/commands/basectl/tests/export-context.bats +++ b/cli/bash/commands/basectl/tests/export-context.bats @@ -11,6 +11,8 @@ load ./basectl_helpers.bash [[ "$output" == *"basectl export-context [project] [options]"* ]] [[ "$output" == *"--format "* ]] [[ "$output" == *"--list-files"* ]] + [[ "$output" == *"base/exports/ in the Base cache"* ]] + [[ "$output" == *"Use --output to choose another destination"* ]] } @test "basectl export-context parses options through reusable arg helper" { diff --git a/cli/python/base_export_context/engine.py b/cli/python/base_export_context/engine.py index d5e41995..e8e28271 100644 --- a/cli/python/base_export_context/engine.py +++ b/cli/python/base_export_context/engine.py @@ -8,6 +8,7 @@ from pathlib import Path, PurePosixPath import base_cli +from base_cli_adapters.paths import base_cache_root from base_cli_profile import base_cli_app @@ -55,7 +56,10 @@ def main(argv: list[str] | None = None) -> int: @base_cli.option("--project-name", required=True, help="Resolved Base project name.") @base_cli.option("--project-root", required=True, help="Resolved Base project root.") @base_cli.option("--format", "output_format", default=MARKDOWN_FORMAT, help="Output format: markdown or zip.") -@base_cli.option("--output", "output_path", help="Output path for the export bundle.") +@base_cli.option( + "--output", "output_path", + help="Output path for the export bundle. Defaults to base/exports/ under the Base cache root.", +) @base_cli.option("--print", "print_bundle", is_flag=True, help="Print the Markdown bundle to stdout.") @base_cli.option("--list-files", is_flag=True, help="Print the files in export order without writing a bundle.") # pylint: disable=too-many-arguments,too-many-positional-arguments @@ -146,7 +150,7 @@ def resolve_output_path(project_name: str, output_path: str | None, extension: s if path.is_dir(): return path / default_filename return path - return Path.cwd() / default_filename + return base_cache_root() / "base" / "exports" / default_filename def ordered_context_files(project_name: str, project_root: Path, include_all: bool) -> tuple[ContextFile, ...]: diff --git a/cli/python/base_export_context/tests/test_engine.py b/cli/python/base_export_context/tests/test_engine.py index 0ef7bc25..9443a4e3 100644 --- a/cli/python/base_export_context/tests/test_engine.py +++ b/cli/python/base_export_context/tests/test_engine.py @@ -5,7 +5,7 @@ import tempfile import unittest import zipfile -from contextlib import redirect_stderr, redirect_stdout +from contextlib import contextmanager, redirect_stderr, redirect_stdout from pathlib import Path from unittest import mock @@ -19,6 +19,16 @@ def write_context_file(project_root: Path, relative_path: str, content: str) -> path.write_text(content, encoding="utf-8") +@contextmanager +def chdir(path: Path): + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + def invoke_engine(args: list[str], project_root: Path) -> tuple[int, str, str]: stdout = io.StringIO() stderr = io.StringIO() @@ -33,6 +43,82 @@ def invoke_engine(args: list[str], project_root: Path) -> tuple[int, str, str]: class ExportContextTests(unittest.TestCase): + def test_default_exports_use_base_cache_from_current_or_other_project(self) -> None: + for output_format, extension in (("markdown", "md"), ("zip", "zip")): + for caller_name in ("demo", "other"): + with self.subTest(output_format=output_format, caller=caller_name): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir).resolve() + project_root = root / "demo" + other_project = root / "other" + for project in (project_root, other_project): + project.mkdir() + (project / "base_manifest.yaml").write_text( + f"project:\n name: {project.name}\nartifacts: []\n", + encoding="utf-8", + ) + write_context_file(project_root, "PROJECT.md", "Project\n") + cache_root = root / "cache" + filename = f"demo-ai-context.{extension}" + destination = cache_root / "base" / "exports" / filename + + with chdir(root / caller_name), mock.patch.dict( + os.environ, {"BASE_CACHE_DIR": str(cache_root)} + ): + status, stdout, stderr = invoke_engine( + ["--project-name", "demo", "--project-root", str(project_root), + "--format", output_format], + project_root, + ) + + self.assertEqual((status, stderr), (0, "")) + self.assertIn(f" to {destination}\n", stdout) + self.assertTrue(destination.is_file()) + self.assertFalse((project_root / filename).exists()) + self.assertFalse((other_project / filename).exists()) + if output_format == "markdown": + self.assertIn("Project\n", destination.read_text(encoding="utf-8")) + else: + with zipfile.ZipFile(destination) as archive: + self.assertEqual(archive.read("PROJECT.md"), b"Project\n") + + def test_explicit_relative_output_can_write_inside_project(self) -> None: + for output_format, extension in (("markdown", "md"), ("zip", "zip")): + with self.subTest(output_format=output_format): + with tempfile.TemporaryDirectory() as tmpdir: + project_root = Path(tmpdir).resolve() / "demo" + write_context_file(project_root, "PROJECT.md", "Project\n") + cache_root = Path(tmpdir) / "cache" + output_path = Path("exports") / f"chosen.{extension}" + with chdir(project_root), mock.patch.dict( + os.environ, {"BASE_CACHE_DIR": str(cache_root)} + ): + status, stdout, stderr = invoke_engine( + ["--project-name", "demo", "--project-root", str(project_root), + "--format", output_format, "--output", str(output_path)], + project_root, + ) + self.assertEqual((status, stderr), (0, "")) + self.assertIn(f" to {output_path}\n", stdout) + self.assertTrue((project_root / output_path).is_file()) + self.assertFalse((cache_root / "base" / "exports").exists()) + + def test_stdout_modes_do_not_create_export_directory(self) -> None: + for mode in ("--print", "--list-files"): + with self.subTest(mode=mode): + with tempfile.TemporaryDirectory() as tmpdir: + project_root = Path(tmpdir) / "demo" + write_context_file(project_root, "PROJECT.md", "Project\n") + cache_root = Path(tmpdir) / "cache" + with mock.patch.dict(os.environ, {"BASE_CACHE_DIR": str(cache_root)}): + status, stdout, stderr = invoke_engine( + ["--project-name", "demo", "--project-root", str(project_root), mode], + project_root, + ) + self.assertEqual((status, stderr), (0, "")) + self.assertIn(".ai-context/PROJECT.md", stdout) + self.assertFalse((cache_root / "base" / "exports").exists()) + def test_explicit_project_root_populates_history_project_metadata(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) diff --git a/docs/command-reference.md b/docs/command-reference.md index 70008271..03dc2fcf 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1004,6 +1004,14 @@ Base-managed project. Markdown exports combine context Markdown files with stable source headings, using `.ai-context/INDEX.md` order when available and falling back to deterministic filename order for unlisted files. Zip exports contain only files from `.ai-context/` so they can be uploaded manually. +Without `--output`, bundles are written to +`$BASE_CACHE_DIR/base/exports/-ai-context.md` (or `.zip`). Base's normal +cache root defaults to `~/Library/Caches/base` on macOS and `~/.cache/base` on +Linux, so running from a project directory does not put the bundle there. +An explicit `--output` file or existing directory overrides this default; +relative paths are relative to the current directory, and project-directory +destinations are allowed when explicitly requested. `--print` and `--list-files` +do not write an export bundle. Exports fail closed on a symlinked context root, symlinked descendants, and special files; only regular files stored inside the real context directory are eligible. From 62a60d8b7cadf38fabd9b8a2d2e4937b4d37e83a Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:29:47 +0530 Subject: [PATCH 2/2] fix: isolate AI context exports by canonical checkout --- .ai-context/COMMANDS.md | 3 +- .../basectl/subcommands/export_context.sh | 3 +- .../basectl/tests/export-context.bats | 2 +- cli/python/base_export_context/engine.py | 12 ++--- .../base_export_context/tests/test_engine.py | 54 ++++++++++++++++++- docs/command-reference.md | 7 ++- 6 files changed, 69 insertions(+), 12 deletions(-) diff --git a/.ai-context/COMMANDS.md b/.ai-context/COMMANDS.md index a1b5dc0f..5dec535d 100644 --- a/.ai-context/COMMANDS.md +++ b/.ai-context/COMMANDS.md @@ -48,7 +48,8 @@ the v1.x compatibility window; new automation should use `--debug-wrapper`. - `basectl run [project] ` - run a declared command for the positional, explicit, or nearest project. - `basectl export-context [project]` - export `.ai-context/` as a Markdown or Zip bundle for manual upload or copy/paste into AI tools. Defaults to - `$BASE_CACHE_DIR/base/exports/-ai-context.md` (or `.zip`); + `$BASE_CACHE_DIR/base/exports//-ai-context.md` (or `.zip`), + using a stable hash of the canonical project root to isolate same-named checkouts; `--output` explicitly selects another destination, including project directories. `--print` and `--list-files` do not write a bundle. - `basectl trust status [project]` - inspect one project's manifest command diff --git a/cli/bash/commands/basectl/subcommands/export_context.sh b/cli/bash/commands/basectl/subcommands/export_context.sh index 17840a10..d7d4f264 100644 --- a/cli/bash/commands/basectl/subcommands/export_context.sh +++ b/cli/bash/commands/basectl/subcommands/export_context.sh @@ -28,8 +28,9 @@ Options: Export a Base-managed project's .ai-context directory for manual upload or copy/paste into AI tools. -Without --output, bundles are written under base/exports/ in the Base cache +Without --output, bundles are written under base/exports// in the Base cache root (BASE_CACHE_DIR), using -ai-context.md or -ai-context.zip. +The checkout ID is derived from the canonical project root, isolating same-named checkouts. Use --output to choose another destination, including a project directory. EOF } diff --git a/cli/bash/commands/basectl/tests/export-context.bats b/cli/bash/commands/basectl/tests/export-context.bats index 7719cbf0..6d4ad7d6 100644 --- a/cli/bash/commands/basectl/tests/export-context.bats +++ b/cli/bash/commands/basectl/tests/export-context.bats @@ -11,7 +11,7 @@ load ./basectl_helpers.bash [[ "$output" == *"basectl export-context [project] [options]"* ]] [[ "$output" == *"--format "* ]] [[ "$output" == *"--list-files"* ]] - [[ "$output" == *"base/exports/ in the Base cache"* ]] + [[ "$output" == *"base/exports// in the Base cache"* ]] [[ "$output" == *"Use --output to choose another destination"* ]] } diff --git a/cli/python/base_export_context/engine.py b/cli/python/base_export_context/engine.py index e8e28271..1b317d99 100644 --- a/cli/python/base_export_context/engine.py +++ b/cli/python/base_export_context/engine.py @@ -8,7 +8,7 @@ from pathlib import Path, PurePosixPath import base_cli -from base_cli_adapters.paths import base_cache_root +from base_cli_adapters.paths import base_cache_root, checkout_id from base_cli_profile import base_cli_app @@ -58,7 +58,7 @@ def main(argv: list[str] | None = None) -> int: @base_cli.option("--format", "output_format", default=MARKDOWN_FORMAT, help="Output format: markdown or zip.") @base_cli.option( "--output", "output_path", - help="Output path for the export bundle. Defaults to base/exports/ under the Base cache root.", + help="Output path for the export bundle. Defaults to base/exports// under the Base cache root.", ) @base_cli.option("--print", "print_bundle", is_flag=True, help="Print the Markdown bundle to stdout.") @base_cli.option("--list-files", is_flag=True, help="Print the files in export order without writing a bundle.") @@ -125,7 +125,7 @@ def export_context(options: ExportContextOptions) -> int: return export_markdown_context(options) files = ordered_context_files(options.project_name, options.project_root, include_all=True) - destination = resolve_output_path(options.project_name, options.output_path, "zip") + destination = resolve_output_path(options.project_name, options.project_root, options.output_path, "zip") write_zip_file(destination, files) print(f"Wrote Zip AI context export for project '{options.project_name}' to {destination}") return base_cli.ExitCode.SUCCESS @@ -137,20 +137,20 @@ def export_markdown_context(options: ExportContextOptions) -> int: if options.print_bundle: print(content, end="") return base_cli.ExitCode.SUCCESS - destination = resolve_output_path(options.project_name, options.output_path, "md") + destination = resolve_output_path(options.project_name, options.project_root, options.output_path, "md") write_text_file(destination, content) print(f"Wrote Markdown AI context export for project '{options.project_name}' to {destination}") return base_cli.ExitCode.SUCCESS -def resolve_output_path(project_name: str, output_path: str | None, extension: str) -> Path: +def resolve_output_path(project_name: str, project_root: Path, output_path: str | None, extension: str) -> Path: default_filename = f"{project_name}-ai-context.{extension}" if output_path: path = Path(output_path).expanduser() if path.is_dir(): return path / default_filename return path - return base_cache_root() / "base" / "exports" / default_filename + return base_cache_root() / "base" / "exports" / checkout_id(project_root) / default_filename def ordered_context_files(project_name: str, project_root: Path, include_all: bool) -> tuple[ContextFile, ...]: diff --git a/cli/python/base_export_context/tests/test_engine.py b/cli/python/base_export_context/tests/test_engine.py index 9443a4e3..da9d9dc5 100644 --- a/cli/python/base_export_context/tests/test_engine.py +++ b/cli/python/base_export_context/tests/test_engine.py @@ -10,6 +10,7 @@ from unittest import mock from base_cli_adapters.history import build_finished_record +from base_cli_adapters.paths import checkout_id from base_export_context import engine @@ -43,6 +44,57 @@ def invoke_engine(args: list[str], project_root: Path) -> tuple[int, str, str]: class ExportContextTests(unittest.TestCase): + def test_default_exports_isolate_same_named_checkouts_and_reuse_canonical_root(self) -> None: + for output_format, extension in (("markdown", "md"), ("zip", "zip")): + with self.subTest(output_format=output_format), tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir).resolve() + first_root = root / "first" / "demo" + second_root = root / "second" / "demo" + write_context_file(first_root, "PROJECT.md", "First checkout\n") + write_context_file(second_root, "PROJECT.md", "Second checkout\n") + cache_root = root / "cache" + filename = f"demo-ai-context.{extension}" + with mock.patch.dict(os.environ, {"BASE_CACHE_DIR": str(cache_root)}): + with chdir(first_root): + status, _, stderr = invoke_engine( + ["--project-name", "demo", "--project-root", str(first_root), + "--format", output_format], + first_root, + ) + self.assertEqual((status, stderr), (0, "")) + first_destination, = cache_root.rglob(filename) + first_content = first_destination.read_bytes() + + with chdir(second_root): + status, _, stderr = invoke_engine( + ["--project-name", "demo", "--project-root", str(second_root), + "--format", output_format], + second_root, + ) + self.assertEqual((status, stderr), (0, "")) + self.assertEqual(first_destination.read_bytes(), first_content) + destinations = set(cache_root.rglob(filename)) + self.assertEqual(len(destinations), 2) + second_destination, = destinations - {first_destination} + if output_format == "markdown": + self.assertIn("Second checkout\n", second_destination.read_text(encoding="utf-8")) + else: + with zipfile.ZipFile(second_destination) as archive: + self.assertEqual(archive.read("PROJECT.md"), b"Second checkout\n") + + alias = root / "alias" + alias.symlink_to(first_root, target_is_directory=True) + with chdir(second_root): + status, stdout, stderr = invoke_engine( + ["--project-name", "demo", "--project-root", str(alias), + "--format", output_format], + first_root, + ) + self.assertEqual((status, stderr), (0, "")) + self.assertIn(f" to {first_destination}\n", stdout) + self.assertEqual(set(cache_root.rglob(filename)), destinations) + self.assertEqual(first_destination.read_bytes(), first_content) + def test_default_exports_use_base_cache_from_current_or_other_project(self) -> None: for output_format, extension in (("markdown", "md"), ("zip", "zip")): for caller_name in ("demo", "other"): @@ -60,7 +112,7 @@ def test_default_exports_use_base_cache_from_current_or_other_project(self) -> N write_context_file(project_root, "PROJECT.md", "Project\n") cache_root = root / "cache" filename = f"demo-ai-context.{extension}" - destination = cache_root / "base" / "exports" / filename + destination = cache_root / "base" / "exports" / checkout_id(project_root) / filename with chdir(root / caller_name), mock.patch.dict( os.environ, {"BASE_CACHE_DIR": str(cache_root)} diff --git a/docs/command-reference.md b/docs/command-reference.md index 5a16be83..85b63ac1 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1005,8 +1005,11 @@ stable source headings, using `.ai-context/INDEX.md` order when available and falling back to deterministic filename order for unlisted files. Zip exports contain only files from `.ai-context/` so they can be uploaded manually. Without `--output`, bundles are written to -`$BASE_CACHE_DIR/base/exports/-ai-context.md` (or `.zip`). Base's normal -cache root defaults to `~/Library/Caches/base` on macOS and `~/.cache/base` on +`$BASE_CACHE_DIR/base/exports//-ai-context.md` (or `.zip`). +The checkout ID is a stable 12-character hash of the canonical project-root path, +so same-named checkouts and worktrees keep separate exports. Re-exporting the same +checkout, including through a symlink or from another directory, updates its same bundle. +Base's normal cache root defaults to `~/Library/Caches/base` on macOS and `~/.cache/base` on Linux, so running from a project directory does not put the bundle there. An explicit `--output` file or existing directory overrides this default; relative paths are relative to the current directory, and project-directory