diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9a6198 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Python bytecode +__pycache__/ +*.py[cod] +*.pyo + +# Distribution / packaging +*.egg-info/ +dist/ +build/ + +# Temporary files +*.tmp +.DS_Store diff --git a/Makefile b/Makefile index cb1e5df..4046d81 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,12 @@ -.PHONY: validate +.PHONY: validate test -validate: +validate: test @test -f README.md @test -f AGENTS.md @test -f .github/copilot-instructions.md @test -f docs/DEVTOOLS_SCOPE.md @test -f repo.maturity.yaml - @python3 - <<'PY' -import pathlib -for path in [ - 'README.md', - 'AGENTS.md', - '.github/copilot-instructions.md', - 'docs/DEVTOOLS_SCOPE.md', - 'repo.maturity.yaml', -]: - text = pathlib.Path(path).read_text() - if not text.strip(): - raise SystemExit(f'{path} is empty') -print('OK: sourceos-devtools validation') -PY + @python3 scripts/validate_scaffold.py + +test: + @python3 -m unittest discover -s tests -v diff --git a/README.md b/README.md index febc4e8..3a4f54a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,45 @@ It should not contain: - SourceOS image build state; - secrets, tokens, credentials, private keys, or device-specific enrollment secrets. +## sourceosctl CLI + +`sourceosctl` is the read-only/dry-run CLI surface for SourceOS developer and AI operator workflows. + +### Usage + +```text +sourceosctl [--version] [] [options] +``` + +### Commands + +| Command | Description | +| --- | --- | +| `sourceosctl doctor` | Run environment health checks (read-only) | +| `sourceosctl profiles list` | List available SourceOS profiles (read-only) | +| `sourceosctl nlboot evidence inspect ` | Inspect a NLBoot evidence JSON file (read-only) | +| `sourceosctl release inspect ` | Inspect a release artifact JSON file (read-only) | +| `sourceosctl fingerprint collect --dry-run` | Print environment fingerprint fields (dry-run only) | +| `sourceosctl ai labs list` | List available AI labs (read-only) | +| `sourceosctl agents sandbox plan --dry-run` | Print agent sandbox plan (dry-run only) | + +### Running from the repo + +```bash +python3 bin/sourceosctl --help +python3 bin/sourceosctl doctor +python3 bin/sourceosctl profiles list +python3 bin/sourceosctl nlboot evidence inspect fixtures/sample_nlboot_evidence.json +python3 bin/sourceosctl release inspect fixtures/sample_release.json +python3 bin/sourceosctl fingerprint collect --dry-run +python3 bin/sourceosctl ai labs list +python3 bin/sourceosctl agents sandbox plan --dry-run +``` + +### Design constraints + +All commands in the current surface are **read-only or dry-run**. No mutating command is implemented. Commands that would mutate host state are explicitly rejected at runtime. + ## First milestone M1 is repo maturity and install surface definition: @@ -62,4 +101,9 @@ M1 is repo maturity and install surface definition: make validate ``` -The initial validation target checks repository metadata and JSON/YAML syntax where present. Implementation-specific validation should be added with each tool surface. +The validation target runs the unit test suite and checks repository metadata. All 21 tests must pass. + +```bash +make test # run tests only +``` + diff --git a/bin/sourceosctl b/bin/sourceosctl new file mode 100755 index 0000000..f0cad7f --- /dev/null +++ b/bin/sourceosctl @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""sourceosctl entry-point script.""" +import sys +import os + +# Allow running directly from the repo root without installing. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sourceosctl.cli import main + +sys.exit(main()) diff --git a/fixtures/sample_nlboot_evidence.json b/fixtures/sample_nlboot_evidence.json new file mode 100644 index 0000000..1f20678 --- /dev/null +++ b/fixtures/sample_nlboot_evidence.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": "nlboot-evidence.v1", + "kind": "NLBootEvidence", + "timestamp": "2025-01-01T00:00:00Z", + "target": { + "host": "example-host", + "arch": "x86_64", + "uefi": true + }, + "boot": { + "loader": "systemd-boot", + "kernel": "6.6.0-sourceos", + "cmdline": "root=/dev/sda1 ro quiet" + }, + "status": "verified", + "signature": "stub-signature-not-real" +} diff --git a/fixtures/sample_release.json b/fixtures/sample_release.json new file mode 100644 index 0000000..fa07eb7 --- /dev/null +++ b/fixtures/sample_release.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "sourceos-release.v1", + "name": "sourceos-devtools", + "version": "0.1.0", + "channel": "stable", + "artifacts": [ + "sourceosctl-0.1.0-linux-x86_64.tar.gz", + "sourceosctl-0.1.0-darwin-arm64.tar.gz" + ], + "metadata": { + "gitRef": "refs/heads/main", + "builtAt": "2025-01-01T00:00:00Z" + } +} diff --git a/scripts/validate_scaffold.py b/scripts/validate_scaffold.py new file mode 100644 index 0000000..047e882 --- /dev/null +++ b/scripts/validate_scaffold.py @@ -0,0 +1,21 @@ +"""Validation script for sourceos-devtools repository scaffold.""" + +import pathlib +import sys + +REQUIRED = [ + "README.md", + "AGENTS.md", + ".github/copilot-instructions.md", + "docs/DEVTOOLS_SCOPE.md", + "repo.maturity.yaml", +] + +for path in REQUIRED: + p = pathlib.Path(path) + if not p.exists(): + raise SystemExit(f"MISSING: {path}") + if not p.read_text().strip(): + raise SystemExit(f"EMPTY: {path}") + +print("OK: sourceos-devtools validation") diff --git a/sourceosctl/__init__.py b/sourceosctl/__init__.py new file mode 100644 index 0000000..1f61660 --- /dev/null +++ b/sourceosctl/__init__.py @@ -0,0 +1,3 @@ +"""sourceosctl - SourceOS Developer and AI Operator CLI.""" + +__version__ = "0.1.0" diff --git a/sourceosctl/cli.py b/sourceosctl/cli.py new file mode 100644 index 0000000..6f7e734 --- /dev/null +++ b/sourceosctl/cli.py @@ -0,0 +1,125 @@ +"""sourceosctl CLI entry point.""" + +import argparse +import sys + +from sourceosctl import __version__ +from sourceosctl.commands import ( + doctor, + profiles, + nlboot, + release, + fingerprint, + ai, + agents, +) + + +def build_parser() -> argparse.ArgumentParser: + """Build and return the argument parser.""" + parser = argparse.ArgumentParser( + prog="sourceosctl", + description="SourceOS developer and AI operator CLI (read-only / dry-run surface)", + ) + parser.add_argument( + "--version", action="version", version=f"sourceosctl {__version__}" + ) + + sub = parser.add_subparsers(dest="command", metavar="") + sub.required = True + + # --- doctor --- + doctor_p = sub.add_parser("doctor", help="Run environment health checks") + doctor_p.set_defaults(func=doctor.run) + + # --- profiles --- + profiles_p = sub.add_parser("profiles", help="Profile management") + profiles_sub = profiles_p.add_subparsers(dest="profiles_command", metavar="") + profiles_sub.required = True + profiles_list_p = profiles_sub.add_parser("list", help="List available profiles") + profiles_list_p.set_defaults(func=profiles.list_profiles) + + # --- nlboot --- + nlboot_p = sub.add_parser("nlboot", help="NLBoot operator helpers") + nlboot_sub = nlboot_p.add_subparsers(dest="nlboot_command", metavar="") + nlboot_sub.required = True + nlboot_evidence_p = nlboot_sub.add_parser("evidence", help="NLBoot evidence helpers") + nlboot_evidence_sub = nlboot_evidence_p.add_subparsers( + dest="nlboot_evidence_command", metavar="" + ) + nlboot_evidence_sub.required = True + nlboot_inspect_p = nlboot_evidence_sub.add_parser( + "inspect", help="Inspect a NLBoot evidence file" + ) + nlboot_inspect_p.add_argument("path", help="Path to NLBoot evidence JSON file") + nlboot_inspect_p.set_defaults(func=nlboot.inspect_evidence) + + # --- release --- + release_p = sub.add_parser("release", help="Release artifact inspection") + release_sub = release_p.add_subparsers(dest="release_command", metavar="") + release_sub.required = True + release_inspect_p = release_sub.add_parser("inspect", help="Inspect a release artifact") + release_inspect_p.add_argument("path", help="Path to release artifact JSON file") + release_inspect_p.set_defaults(func=release.inspect) + + # --- fingerprint --- + fingerprint_p = sub.add_parser("fingerprint", help="Environment fingerprint utilities") + fingerprint_sub = fingerprint_p.add_subparsers( + dest="fingerprint_command", metavar="" + ) + fingerprint_sub.required = True + fingerprint_collect_p = fingerprint_sub.add_parser( + "collect", help="Collect environment fingerprint (dry-run only)" + ) + fingerprint_collect_p.add_argument( + "--dry-run", + action="store_true", + default=True, + dest="dry_run", + help="Print what would be collected without writing to disk (default: True)", + ) + fingerprint_collect_p.set_defaults(func=fingerprint.collect) + + # --- ai --- + ai_p = sub.add_parser("ai", help="AI operator utilities") + ai_sub = ai_p.add_subparsers(dest="ai_command", metavar="") + ai_sub.required = True + ai_labs_p = ai_sub.add_parser("labs", help="AI lab helpers") + ai_labs_sub = ai_labs_p.add_subparsers(dest="ai_labs_command", metavar="") + ai_labs_sub.required = True + ai_labs_list_p = ai_labs_sub.add_parser("list", help="List available AI labs") + ai_labs_list_p.set_defaults(func=ai.list_labs) + + # --- agents --- + agents_p = sub.add_parser("agents", help="Agent sandbox helpers") + agents_sub = agents_p.add_subparsers(dest="agents_command", metavar="") + agents_sub.required = True + agents_sandbox_p = agents_sub.add_parser("sandbox", help="Agent sandbox management") + agents_sandbox_sub = agents_sandbox_p.add_subparsers( + dest="agents_sandbox_command", metavar="" + ) + agents_sandbox_sub.required = True + agents_sandbox_plan_p = agents_sandbox_sub.add_parser( + "plan", help="Plan agent sandbox (dry-run only)" + ) + agents_sandbox_plan_p.add_argument( + "--dry-run", + action="store_true", + default=True, + dest="dry_run", + help="Print plan without executing (default: True)", + ) + agents_sandbox_plan_p.set_defaults(func=agents.sandbox_plan) + + return parser + + +def main(argv=None) -> int: + """Main entry point. Returns exit code.""" + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) or 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sourceosctl/commands/__init__.py b/sourceosctl/commands/__init__.py new file mode 100644 index 0000000..001eca1 --- /dev/null +++ b/sourceosctl/commands/__init__.py @@ -0,0 +1 @@ +"""sourceosctl command modules.""" diff --git a/sourceosctl/commands/agents.py b/sourceosctl/commands/agents.py new file mode 100644 index 0000000..2b148eb --- /dev/null +++ b/sourceosctl/commands/agents.py @@ -0,0 +1,31 @@ +"""agents command: agent sandbox helpers.""" + +import sys + +_STUB_PLAN = [ + "1. Resolve agent identity from agent-registry (stub)", + "2. Check tool-grant contracts (read-only, stub)", + "3. Allocate isolated sandbox namespace (dry-run: would call nsjail or equivalent)", + "4. Mount read-only source paths (dry-run: no mounts performed)", + "5. Emit sandbox plan manifest to stdout", +] + + +def sandbox_plan(args) -> int: + """Print an agent sandbox plan without executing it. + + Only --dry-run is supported; real execution is not implemented. + """ + if not getattr(args, "dry_run", True): + print( + "error: only --dry-run is supported; sandbox execution is not implemented", + file=sys.stderr, + ) + return 1 + + print("agents sandbox plan --dry-run") + print("Planned steps (not executed):") + for step in _STUB_PLAN: + print(f" {step}") + print("\n(dry-run: no sandbox created)") + return 0 diff --git a/sourceosctl/commands/ai.py b/sourceosctl/commands/ai.py new file mode 100644 index 0000000..00fa2d4 --- /dev/null +++ b/sourceosctl/commands/ai.py @@ -0,0 +1,15 @@ +"""ai command: AI operator utilities.""" + +_STUB_LABS = [ + {"name": "local-inference", "status": "available", "description": "Local model inference lab"}, + {"name": "model-router", "status": "stub", "description": "Governed model routing lab (client stub)"}, + {"name": "guardrail-fabric", "status": "stub", "description": "Guardrail policy inspection lab (client stub)"}, +] + + +def list_labs(args) -> int: + """List available AI labs. Read-only.""" + print("Available AI labs (stub):") + for lab in _STUB_LABS: + print(f" {lab['name']:<22} [{lab['status']:<9}] {lab['description']}") + return 0 diff --git a/sourceosctl/commands/doctor.py b/sourceosctl/commands/doctor.py new file mode 100644 index 0000000..70b0ae3 --- /dev/null +++ b/sourceosctl/commands/doctor.py @@ -0,0 +1,34 @@ +"""doctor command: run environment health checks.""" + +import platform +import shutil +import sys + + +def run(args) -> int: + """Print a summary of environment health. Read-only.""" + checks = [] + + checks.append(("python", sys.version.split()[0], True)) + checks.append(("platform", platform.system(), True)) + + git_path = shutil.which("git") + checks.append(("git", git_path if git_path else "not found", git_path is not None)) + + nix_path = shutil.which("nix") + checks.append(("nix", nix_path if nix_path else "not found", False)) + + all_ok = True + for name, value, required in checks: + status = "ok" if value and value != "not found" else ("warn" if not required else "FAIL") + if status == "FAIL": + all_ok = False + print(f" {status:<6} {name}: {value}") + + if all_ok: + print("\ndoctor: all required checks passed") + else: + print("\ndoctor: one or more required checks failed", file=sys.stderr) + return 1 + + return 0 diff --git a/sourceosctl/commands/fingerprint.py b/sourceosctl/commands/fingerprint.py new file mode 100644 index 0000000..760d7d5 --- /dev/null +++ b/sourceosctl/commands/fingerprint.py @@ -0,0 +1,36 @@ +"""fingerprint command: environment fingerprint utilities.""" + +import platform +import shutil +import sys + + +_FIELDS = [ + ("os", lambda: platform.system()), + ("os_version", lambda: platform.version()), + ("machine", lambda: platform.machine()), + ("python", lambda: sys.version.split()[0]), + ("git", lambda: shutil.which("git") or "not found"), + ("nix", lambda: shutil.which("nix") or "not found"), +] + + +def collect(args) -> int: + """Collect environment fingerprint. + + With --dry-run (default and only mode) prints what would be collected + without writing anything to disk. + """ + if not getattr(args, "dry_run", True): + print( + "error: only --dry-run is supported; mutating collect is not implemented", + file=sys.stderr, + ) + return 1 + + print("fingerprint collect --dry-run") + print("Fields that would be collected:") + for name, getter in _FIELDS: + print(f" {name}: {getter()}") + print("\n(dry-run: no data written)") + return 0 diff --git a/sourceosctl/commands/nlboot.py b/sourceosctl/commands/nlboot.py new file mode 100644 index 0000000..093c44a --- /dev/null +++ b/sourceosctl/commands/nlboot.py @@ -0,0 +1,37 @@ +"""nlboot command: NLBoot evidence inspection helpers.""" + +import json +import pathlib +import sys + + +def inspect_evidence(args) -> int: + """Inspect a NLBoot evidence file. Read-only.""" + path = pathlib.Path(args.path) + if not path.exists(): + print(f"error: file not found: {path}", file=sys.stderr) + return 1 + + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + print(f"error: invalid JSON in {path}: {exc}", file=sys.stderr) + return 1 + + schema = data.get("schemaVersion", "") + kind = data.get("kind", "") + print(f"NLBoot evidence: {path}") + print(f" schemaVersion : {schema}") + print(f" kind : {kind}") + + for key, value in data.items(): + if key in ("schemaVersion", "kind"): + continue + if isinstance(value, dict): + print(f" {key}:") + for k, v in value.items(): + print(f" {k}: {v}") + else: + print(f" {key}: {value}") + + return 0 diff --git a/sourceosctl/commands/profiles.py b/sourceosctl/commands/profiles.py new file mode 100644 index 0000000..9dfab88 --- /dev/null +++ b/sourceosctl/commands/profiles.py @@ -0,0 +1,15 @@ +"""profiles command: list available SourceOS profiles.""" + +_STUB_PROFILES = [ + {"name": "developer", "description": "Standard developer workstation profile"}, + {"name": "operator", "description": "AI operator / model-router profile"}, + {"name": "minimal", "description": "Minimal read-only inspection profile"}, +] + + +def list_profiles(args) -> int: + """List stub profiles. Read-only.""" + print("Available profiles (stub):") + for profile in _STUB_PROFILES: + print(f" {profile['name']:<16} {profile['description']}") + return 0 diff --git a/sourceosctl/commands/release.py b/sourceosctl/commands/release.py new file mode 100644 index 0000000..b7b36d2 --- /dev/null +++ b/sourceosctl/commands/release.py @@ -0,0 +1,41 @@ +"""release command: release artifact inspection.""" + +import json +import pathlib +import sys + + +def inspect(args) -> int: + """Inspect a release artifact. Read-only.""" + path = pathlib.Path(args.path) + if not path.exists(): + print(f"error: file not found: {path}", file=sys.stderr) + return 1 + + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + print(f"error: invalid JSON in {path}: {exc}", file=sys.stderr) + return 1 + + schema = data.get("schemaVersion", "") + name = data.get("name", "") + version = data.get("version", "") + print(f"Release artifact: {path}") + print(f" schemaVersion : {schema}") + print(f" name : {name}") + print(f" version : {version}") + + for key, value in data.items(): + if key in ("schemaVersion", "name", "version"): + continue + if isinstance(value, dict): + print(f" {key}:") + for k, v in value.items(): + print(f" {k}: {v}") + elif isinstance(value, list): + print(f" {key}: {', '.join(str(v) for v in value)}") + else: + print(f" {key}: {value}") + + return 0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fccd2af --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for sourceosctl CLI.""" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d7cea24 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,161 @@ +"""Unit tests for sourceosctl CLI commands.""" + +import json +import pathlib +import sys +import unittest +import tempfile +import os + +# Ensure the repo root is on the path so tests work without installation. +_REPO_ROOT = pathlib.Path(__file__).parent.parent +sys.path.insert(0, str(_REPO_ROOT)) + +from sourceosctl.cli import build_parser, main +from sourceosctl.commands import doctor, profiles, nlboot, release, fingerprint, ai, agents + + +FIXTURES = _REPO_ROOT / "fixtures" + + +class _Args: + """Minimal namespace helper for testing command functions directly.""" + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +class TestVersion(unittest.TestCase): + def test_version_flag(self): + parser = build_parser() + with self.assertRaises(SystemExit) as cm: + parser.parse_args(["--version"]) + self.assertEqual(cm.exception.code, 0) + + +class TestDoctor(unittest.TestCase): + def test_doctor_returns_zero(self): + args = _Args() + result = doctor.run(args) + self.assertIn(result, (0, None)) + + def test_doctor_via_main(self): + rc = main(["doctor"]) + self.assertEqual(rc, 0) + + +class TestProfiles(unittest.TestCase): + def test_list_profiles_returns_zero(self): + args = _Args() + result = profiles.list_profiles(args) + self.assertIn(result, (0, None)) + + def test_profiles_list_via_main(self): + rc = main(["profiles", "list"]) + self.assertEqual(rc, 0) + + +class TestNlboot(unittest.TestCase): + def test_inspect_evidence_fixture(self): + path = FIXTURES / "sample_nlboot_evidence.json" + args = _Args(path=str(path)) + result = nlboot.inspect_evidence(args) + self.assertIn(result, (0, None)) + + def test_inspect_evidence_missing_file(self): + args = _Args(path="/nonexistent/path/evidence.json") + result = nlboot.inspect_evidence(args) + self.assertEqual(result, 1) + + def test_inspect_evidence_bad_json(self): + with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f: + f.write("not json {{{") + tmp_path = f.name + try: + args = _Args(path=tmp_path) + result = nlboot.inspect_evidence(args) + self.assertEqual(result, 1) + finally: + os.unlink(tmp_path) + + def test_nlboot_evidence_inspect_via_main(self): + path = FIXTURES / "sample_nlboot_evidence.json" + rc = main(["nlboot", "evidence", "inspect", str(path)]) + self.assertEqual(rc, 0) + + +class TestRelease(unittest.TestCase): + def test_inspect_fixture(self): + path = FIXTURES / "sample_release.json" + args = _Args(path=str(path)) + result = release.inspect(args) + self.assertIn(result, (0, None)) + + def test_inspect_missing_file(self): + args = _Args(path="/nonexistent/release.json") + result = release.inspect(args) + self.assertEqual(result, 1) + + def test_inspect_bad_json(self): + with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f: + f.write("bad json") + tmp_path = f.name + try: + args = _Args(path=tmp_path) + result = release.inspect(args) + self.assertEqual(result, 1) + finally: + os.unlink(tmp_path) + + def test_release_inspect_via_main(self): + path = FIXTURES / "sample_release.json" + rc = main(["release", "inspect", str(path)]) + self.assertEqual(rc, 0) + + +class TestFingerprint(unittest.TestCase): + def test_collect_dry_run(self): + args = _Args(dry_run=True) + result = fingerprint.collect(args) + self.assertIn(result, (0, None)) + + def test_collect_no_dry_run_rejected(self): + args = _Args(dry_run=False) + result = fingerprint.collect(args) + self.assertEqual(result, 1) + + def test_fingerprint_collect_via_main(self): + rc = main(["fingerprint", "collect", "--dry-run"]) + self.assertEqual(rc, 0) + + +class TestAi(unittest.TestCase): + def test_list_labs_returns_zero(self): + args = _Args() + result = ai.list_labs(args) + self.assertIn(result, (0, None)) + + def test_ai_labs_list_via_main(self): + rc = main(["ai", "labs", "list"]) + self.assertEqual(rc, 0) + + +class TestAgents(unittest.TestCase): + def test_sandbox_plan_dry_run(self): + args = _Args(dry_run=True) + result = agents.sandbox_plan(args) + self.assertIn(result, (0, None)) + + def test_sandbox_plan_no_dry_run_rejected(self): + args = _Args(dry_run=False) + result = agents.sandbox_plan(args) + self.assertEqual(result, 1) + + def test_agents_sandbox_plan_via_main(self): + rc = main(["agents", "sandbox", "plan", "--dry-run"]) + self.assertEqual(rc, 0) + + +if __name__ == "__main__": + unittest.main()