Files

210 lines
9.1 KiB
Python

#!/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(),
}
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()