Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .ai-context/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ the v1.x compatibility window; new automation should use `--debug-wrapper`.
- `basectl demo [project]` - run a declared interactive demo script.
- `basectl run [project] <command>` - 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/<checkout-id>/<project>-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
trust or all discovered command-bearing projects.
- Generated onboarding trust commands include the canonical workspace and
Expand Down
5 changes: 5 additions & 0 deletions cli/bash/commands/basectl/subcommands/export_context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ 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/<checkout-id>/ in the Base cache
root (BASE_CACHE_DIR), using <project>-ai-context.md or <project>-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
}

Expand Down
2 changes: 2 additions & 0 deletions cli/bash/commands/basectl/tests/export-context.bats
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ load ./basectl_helpers.bash
[[ "$output" == *"basectl export-context [project] [options]"* ]]
[[ "$output" == *"--format <markdown|zip>"* ]]
[[ "$output" == *"--list-files"* ]]
[[ "$output" == *"base/exports/<checkout-id>/ in the Base cache"* ]]
[[ "$output" == *"Use --output to choose another destination"* ]]
}

@test "basectl export-context parses options through reusable arg helper" {
Expand Down
14 changes: 9 additions & 5 deletions cli/python/base_export_context/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path, PurePosixPath

import base_cli
from base_cli_adapters.paths import base_cache_root, checkout_id
from base_cli_profile import base_cli_app


Expand Down Expand Up @@ -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/<checkout-id>/ 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
Expand Down Expand Up @@ -121,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
Expand All @@ -133,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 Path.cwd() / 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, ...]:
Expand Down
140 changes: 139 additions & 1 deletion cli/python/base_export_context/tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
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

from base_cli_adapters.history import build_finished_record
from base_cli_adapters.paths import checkout_id
from base_export_context import engine


Expand All @@ -19,6 +20,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()
Expand All @@ -33,6 +44,133 @@ 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"):
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" / checkout_id(project_root) / 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)
Expand Down
11 changes: 11 additions & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,17 @@ 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/<checkout-id>/<project>-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
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.
Expand Down
Loading