feat: manifest-driven installer, lite mode, tests, and CI#3
Conversation
Refactor installer to read file lists from MANIFEST.yaml instead of hardcoded list, eliminating the dual source of truth. Add --lite flag for progressive adoption (3 essential files). Add 12 smoke tests, GitHub Actions CI, and a filled .cortex/ example for onboarding. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Pull request overview
Refactors the CORTEX installer to be manifest-driven, adds a Lite installation mode, and introduces smoke tests + CI to validate installer behavior and manifest/template consistency.
Changes:
- Refactor installer to source install file lists from
MANIFEST.yamland add--litesupport. - Add installer smoke tests covering list-files, full/lite install, dry-run, force/skip behavior, and lite→full upgrade.
- Add CI workflow to run the smoke tests across Python 3.10/3.11/3.12 and add onboarding examples/docs updates.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
scripts/install_cortex.py |
Loads file lists from MANIFEST.yaml, adds --lite, updates output formatting, and supports optional PyYAML with fallback parsing. |
MANIFEST.yaml |
Adds lite_files and remains the source of truth for required_project_files. |
tests/test_installer.py |
Adds smoke tests validating manifest/template consistency and core installer behaviors. |
.github/workflows/ci.yml |
Adds CI job to run smoke tests and basic installer verification across Python versions. |
docs/installing.md |
Documents Lite install flow and manifest-driven file selection. |
docs/templates.md |
Documents Lite vs Full template sets and links to filled example. |
README.md |
Updates onboarding/install expectations, mentions Lite mode, tests, examples, and CI. |
examples/filled-cortex/README.md |
Adds guidance for the filled .cortex/ example. |
examples/filled-cortex/PROJECT_CONTEXT.md |
Adds a realistic filled example of PROJECT_CONTEXT. |
examples/filled-cortex/CURRENT_STATUS.md |
Adds a realistic filled example of CURRENT_STATUS. |
examples/filled-cortex/AGENT_HANDOFF.md |
Adds a realistic filled example of AGENT_HANDOFF. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| with open(MANIFEST) as f: | ||
| return yaml.safe_load(f) | ||
| # Fallback: minimal YAML parser for simple list fields. | ||
| # Avoids forcing a PyYAML dependency on end users. | ||
| data = {} | ||
| current_key = None | ||
| with open(MANIFEST) as f: | ||
| for line in f: | ||
| stripped = line.rstrip() | ||
| if stripped.endswith(":") and not stripped.startswith(" ") and not stripped.startswith("-"): | ||
| current_key = stripped[:-1].strip() | ||
| data[current_key] = [] | ||
| elif stripped.startswith(" - ") and current_key is not None: | ||
| data[current_key].append(stripped.strip("- ").strip()) | ||
| elif not stripped.startswith(" ") and ":" in stripped: | ||
| key, _, value = stripped.partition(":") | ||
| current_key = None | ||
| data[key.strip()] = value.strip() |
There was a problem hiding this comment.
load_manifest() doesn't handle missing/unreadable/invalid MANIFEST.yaml (e.g., open() can raise FileNotFoundError, and yaml.safe_load() can return None). This can lead to an unhelpful crash later (e.g., manifest.get(...) raising AttributeError). Consider catching I/O / parse errors and validating the loaded manifest is a mapping, then SystemExit with a clear message if not.
| with open(MANIFEST) as f: | |
| return yaml.safe_load(f) | |
| # Fallback: minimal YAML parser for simple list fields. | |
| # Avoids forcing a PyYAML dependency on end users. | |
| data = {} | |
| current_key = None | |
| with open(MANIFEST) as f: | |
| for line in f: | |
| stripped = line.rstrip() | |
| if stripped.endswith(":") and not stripped.startswith(" ") and not stripped.startswith("-"): | |
| current_key = stripped[:-1].strip() | |
| data[current_key] = [] | |
| elif stripped.startswith(" - ") and current_key is not None: | |
| data[current_key].append(stripped.strip("- ").strip()) | |
| elif not stripped.startswith(" ") and ":" in stripped: | |
| key, _, value = stripped.partition(":") | |
| current_key = None | |
| data[key.strip()] = value.strip() | |
| try: | |
| with open(MANIFEST) as f: | |
| manifest = yaml.safe_load(f) | |
| except OSError as exc: | |
| raise SystemExit(f"Unable to read {MANIFEST}: {exc}") from exc | |
| except yaml.YAMLError as exc: | |
| raise SystemExit(f"Invalid YAML in {MANIFEST}: {exc}") from exc | |
| if not isinstance(manifest, dict): | |
| raise SystemExit( | |
| f"{MANIFEST} must contain a YAML mapping at the top level." | |
| ) | |
| return manifest | |
| # Fallback: minimal YAML parser for simple list fields. | |
| # Avoids forcing a PyYAML dependency on end users. | |
| data = {} | |
| current_key = None | |
| try: | |
| with open(MANIFEST) as f: | |
| for line in f: | |
| stripped = line.rstrip() | |
| if stripped.endswith(":") and not stripped.startswith(" ") and not stripped.startswith("-"): | |
| current_key = stripped[:-1].strip() | |
| data[current_key] = [] | |
| elif stripped.startswith(" - ") and current_key is not None: | |
| data[current_key].append(stripped.strip("- ").strip()) | |
| elif not stripped.startswith(" ") and ":" in stripped: | |
| key, _, value = stripped.partition(":") | |
| current_key = None | |
| data[key.strip()] = value.strip() | |
| except OSError as exc: | |
| raise SystemExit(f"Unable to read {MANIFEST}: {exc}") from exc | |
| if not isinstance(data, dict): | |
| raise SystemExit( | |
| f"{MANIFEST} must contain a mapping of manifest keys to values." | |
| ) |
| # Fallback: minimal YAML parser for simple list fields. | ||
| # Avoids forcing a PyYAML dependency on end users. | ||
| data = {} | ||
| current_key = None | ||
| with open(MANIFEST) as f: | ||
| for line in f: | ||
| stripped = line.rstrip() | ||
| if stripped.endswith(":") and not stripped.startswith(" ") and not stripped.startswith("-"): | ||
| current_key = stripped[:-1].strip() | ||
| data[current_key] = [] | ||
| elif stripped.startswith(" - ") and current_key is not None: | ||
| data[current_key].append(stripped.strip("- ").strip()) | ||
| elif not stripped.startswith(" ") and ":" in stripped: | ||
| key, _, value = stripped.partition(":") | ||
| current_key = None | ||
| data[key.strip()] = value.strip() |
There was a problem hiding this comment.
The fallback YAML parser sets any top-level key: to an empty list and only supports list items with exactly two-space indentation (" - "). For this repo's MANIFEST.yaml, that means nested mappings like language:, documentation:, branch_policy:, scoring: etc. will be loaded with incorrect types/empty values when PyYAML isn't installed. If the intent is only to support required_project_files/lite_files without a dependency, consider narrowing the fallback to parse just those keys (and ignore other sections) or extending it to preserve top-level mapping types instead of turning them into lists.
| # Fallback: minimal YAML parser for simple list fields. | |
| # Avoids forcing a PyYAML dependency on end users. | |
| data = {} | |
| current_key = None | |
| with open(MANIFEST) as f: | |
| for line in f: | |
| stripped = line.rstrip() | |
| if stripped.endswith(":") and not stripped.startswith(" ") and not stripped.startswith("-"): | |
| current_key = stripped[:-1].strip() | |
| data[current_key] = [] | |
| elif stripped.startswith(" - ") and current_key is not None: | |
| data[current_key].append(stripped.strip("- ").strip()) | |
| elif not stripped.startswith(" ") and ":" in stripped: | |
| key, _, value = stripped.partition(":") | |
| current_key = None | |
| data[key.strip()] = value.strip() | |
| # Fallback: parse only the simple top-level list fields this script uses. | |
| # Avoids forcing a PyYAML dependency on end users without inventing | |
| # incorrect values for unsupported nested YAML sections. | |
| supported_list_keys = {"required_project_files", "lite_files"} | |
| data = {key: [] for key in supported_list_keys} | |
| current_key = None | |
| with open(MANIFEST) as f: | |
| for line in f: | |
| stripped = line.rstrip() | |
| if not stripped or stripped.lstrip().startswith("#"): | |
| continue | |
| if stripped.endswith(":") and not stripped.startswith(" ") and not stripped.startswith("-"): | |
| key = stripped[:-1].strip() | |
| current_key = key if key in supported_list_keys else None | |
| elif stripped.startswith(" - ") and current_key is not None: | |
| data[current_key].append(stripped[4:].strip()) | |
| elif not stripped.startswith(" "): | |
| current_key = None |
Summary
MANIFEST.yamlinstead of hardcoded Python list — single source of truth, no more divergence risk--lite) installs only 3 essential files (PROJECT_CONTEXT, CURRENT_STATUS, AGENT_HANDOFF) — reduces adoption barrier for small projectsexamples/filled-cortex/showing realistic.cortex/files for onboardingMotivation
Independent analysis identified a gap between CORTEX's "operating system" promise and its technical execution. This PR addresses the top priorities:
Test plan
python3 tests/test_installer.py— 12/12 passingpython3 scripts/install_cortex.py --list-files— reads from MANIFEST.yamlpython3 scripts/install_cortex.py --list-files --lite— shows 3 filespython3 scripts/install_cortex.py <target> --dry-run— no files writtenpython3 scripts/install_cortex.py <target> --litethen without--lite— upgrade works🤖 Generated with Claude Code