#!/usr/bin/env python3 """Profile path resolution for AI Workspace scripts. Profiles own their configuration. Reusable scripts should call this module instead of hardcoding project paths. """ from __future__ import annotations import json import argparse import re from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[2] DEFAULT_WORKSPACE = { "knowledge_dir": "workspaces/{profile}/project-knowledge", "inbox_dir": "workspaces/{profile}/inbox", "index_dir": ".aiw/indexes/{profile}", } PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") KNOWLEDGE_DIRS = [ "00-start", "01-current", "02-work-items", "03-context/process", "03-context/systems", "03-context/workstreams", "04-people", "05-decisions", "06-daily", "07-maps", "08-bases", "09-templates", "attachments", ] def workspace_config_path(profile: str, root: Path | None = None) -> Path: base = root or ROOT return base / "profiles" / profile / "workspace.json" def load_workspace_config(profile: str, root: Path | None = None) -> dict[str, Any]: base = root or ROOT config = dict(DEFAULT_WORKSPACE) config["profile"] = profile path = workspace_config_path(profile, root=base) if path.is_file(): try: loaded = json.loads(path.read_text(encoding="utf-8")) if isinstance(loaded, dict): config.update(loaded) except json.JSONDecodeError: pass return config def resolve_path(raw: str | None, *, profile: str, root: Path | None = None, fallback: str) -> Path: base = root or ROOT value = (raw or fallback).format(profile=profile) path = Path(value).expanduser() return path if path.is_absolute() else base / path def knowledge_dir(profile: str, root: Path | None = None) -> Path: config = load_workspace_config(profile, root=root) return resolve_path(config.get("knowledge_dir"), profile=profile, root=root, fallback="workspaces/{profile}/project-knowledge") def inbox_dir(profile: str, root: Path | None = None) -> Path: config = load_workspace_config(profile, root=root) return resolve_path(config.get("inbox_dir"), profile=profile, root=root, fallback="workspaces/{profile}/inbox") def index_dir(profile: str, root: Path | None = None) -> Path: config = load_workspace_config(profile, root=root) return resolve_path(config.get("index_dir"), profile=profile, root=root, fallback=".aiw/indexes/{profile}") def relative_to_root(path: Path, root: Path | None = None) -> str: base = root or ROOT try: return str(path.relative_to(base)) except ValueError: return str(path) def validate_profile_name(profile: str) -> None: if not PROFILE_RE.match(profile): raise SystemExit("profile must be lowercase letters, numbers, dashes, or underscores") def write_file_once(path: Path, text: str) -> None: if path.exists(): return path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") def create_profile(profile: str, display_name: str | None = None, root: Path | None = None) -> dict[str, Any]: validate_profile_name(profile) base = root or ROOT display = display_name or profile.replace("-", " ").replace("_", " ").title() profile_dir = base / "profiles" / profile knowledge = knowledge_dir(profile, root=base) inbox = inbox_dir(profile, root=base) index = index_dir(profile, root=base) if profile_dir.exists() or knowledge.exists() or inbox.exists(): raise SystemExit(f"profile already exists or has data directories: {profile}") profile_dir.mkdir(parents=True) write_file_once(profile_dir / "profile.md", f"# {display} Profile\n\n## Purpose\n\nDescribe how this project uses AI Workspace.\n\n## Project\n\n- Name: {display}\n- Workspace role: companion AI workspace\n\n## Communication Sources\n\n- Live communication: configure if needed\n- Historical archive: optional\n\n## Work System\n\n- Use the profile project knowledge vault for work items and current state.\n") write_file_once(profile_dir / "workspace.json", json.dumps({ "profile": profile, "display_name": display, "description": f"AI Workspace profile for {display}.", "knowledge_dir": relative_to_root(knowledge, root=base), "inbox_dir": relative_to_root(inbox, root=base), "index_dir": relative_to_root(index, root=base), }, ensure_ascii=False, indent=2, sort_keys=True) + "\n") write_file_once(profile_dir / "services.json", json.dumps({"profile": profile, "description": f"Local services for {display}.", "services": {}}, ensure_ascii=False, indent=2, sort_keys=True) + "\n") write_file_once(profile_dir / "context-sources.json", json.dumps({"communication_sources": {}}, ensure_ascii=False, indent=2, sort_keys=True) + "\n") for folder in KNOWLEDGE_DIRS: (knowledge / folder).mkdir(parents=True, exist_ok=True) inbox.mkdir(parents=True, exist_ok=True) write_file_once(knowledge / "00-start" / "start-here.md", f"# {display} Start Here\n\nUse this note as the entry point for the {display} project knowledge vault.\n") write_file_once(knowledge / "01-current" / "current-work.md", "# Current Work\n\n## Focus\n\n- TBD\n") write_file_once(knowledge / "01-current" / "work-items.md", "# Work Items\n\nNo active work items recorded yet.\n") write_file_once(knowledge / "03-context" / "project.md", f"# {display} Project Context\n\nDurable project context goes here.\n") write_file_once(knowledge / "04-people" / "index.md", "# People\n\nProject people and role mappings go here.\n") write_file_once(knowledge / "07-maps" / "index.md", "# Maps\n\nNavigation maps go here.\n") write_file_once(knowledge / "09-templates" / "work-item.md", "---\ntype: work-item\ntitle: {{title}}\nupdated: {{date}}\n---\n\n# {{title}}\n") write_file_once(knowledge / "09-templates" / "daily.md", "---\ntype: daily\ndate: {{date}}\nupdated: {{date}}\n---\n\n# {{date}}\n") write_file_once(inbox / "README.md", f"# {display} Inbox\n\nRaw evidence for this profile. Promote durable facts into the project knowledge vault.\n") return {"profile": profile, "profile_dir": relative_to_root(profile_dir, root=base), "knowledge_dir": relative_to_root(knowledge, root=base), "inbox_dir": relative_to_root(inbox, root=base)} def doctor(profile: str, root: Path | None = None) -> dict[str, Any]: base = root or ROOT config_path = workspace_config_path(profile, root=base) knowledge = knowledge_dir(profile, root=base) inbox = inbox_dir(profile, root=base) checks = { "profile_config_exists": config_path.is_file(), "knowledge_dir_exists": knowledge.is_dir(), "inbox_dir_exists": inbox.is_dir(), "start_here_exists": (knowledge / "00-start" / "start-here.md").is_file(), "current_work_exists": (knowledge / "01-current" / "current-work.md").is_file(), "work_items_exists": (knowledge / "01-current" / "work-items.md").is_file(), "root_project_knowledge_absent": not (base / "project-knowledge").exists(), "root_ai_inbox_absent": not (base / "ai" / "inbox").exists(), } return {"profile": profile, "ok": all(checks.values()), "checks": checks, "paths": {"knowledge_dir": relative_to_root(knowledge, root=base), "inbox_dir": relative_to_root(inbox, root=base), "index_dir": relative_to_root(index_dir(profile, root=base), root=base)}} def main() -> None: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) path_parser = subparsers.add_parser("path", help="Print a resolved profile path.") path_parser.add_argument("kind", choices=["knowledge", "inbox", "index"]) path_parser.add_argument("--profile", default="fidelity") config_parser = subparsers.add_parser("config", help="Print resolved workspace configuration as JSON.") config_parser.add_argument("--profile", default="fidelity") create_parser = subparsers.add_parser("create", help="Create a new isolated project profile.") create_parser.add_argument("profile") create_parser.add_argument("--display-name", default="") doctor_parser = subparsers.add_parser("doctor", help="Validate profile workspace layout.") doctor_parser.add_argument("--profile", default="fidelity") args = parser.parse_args() if args.command == "path": if args.kind == "knowledge": print(knowledge_dir(args.profile)) elif args.kind == "inbox": print(inbox_dir(args.profile)) else: print(index_dir(args.profile)) return if args.command == "create": print(json.dumps(create_profile(args.profile, args.display_name or None), ensure_ascii=False, indent=2, sort_keys=True)) return if args.command == "doctor": payload = doctor(args.profile) print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) raise SystemExit(0 if payload["ok"] else 1) config = load_workspace_config(args.profile) config["resolved"] = { "knowledge_dir": str(knowledge_dir(args.profile)), "inbox_dir": str(inbox_dir(args.profile)), "index_dir": str(index_dir(args.profile)), } print(json.dumps(config, ensure_ascii=False, indent=2, sort_keys=True)) if __name__ == "__main__": main()