#!/usr/bin/env python3 """odysseus-docs — shell wrapper for the living-document store. Documents are the AI-editable text/markdown/code files the assistant creates in-place (think: a scratchpad it can revise). Each document has a content blob + version history. odysseus-docs list [--active] [--limit N] odysseus-docs show DOC_ID # current content + metadata odysseus-docs versions DOC_ID # version history (no content) odysseus-docs export DOC_ID --version N # full content of a specific version odysseus-docs search "invoice" # title + current_content match odysseus-docs delete DOC_ID # soft-delete (is_active=False) Reads `data/app.db` directly. """ from __future__ import annotations import sys import os, sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib")) from cli import quiet_logs, emit, fail, common_parser, run, REPO_ROOT as _REPO_ROOT quiet_logs() import argparse, json, logging, os, sys from pathlib import Path try: from core.database import SessionLocal, Document, DocumentVersion quiet_logs() except ModuleNotFoundError as e: sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n") sys.exit(2) def _text_len(value) -> int: return len(value) if isinstance(value, str) else 0 def _serialize(d: "Document", include_content: bool = False) -> dict: out = { "id": d.id, "title": d.title, "language": d.language or "", "session_id": d.session_id or "", "version_count": d.version_count or 1, "is_active": bool(d.is_active), "tidy_verdict": d.tidy_verdict or "", "content_length": _text_len(d.current_content), "created_at": d.created_at.isoformat() if d.created_at else "", "updated_at": d.updated_at.isoformat() if d.updated_at else "", } if include_content: out["current_content"] = d.current_content or "" return out def cmd_list(args): db = SessionLocal() try: q = db.query(Document) if args.active: q = q.filter(Document.is_active == True) # noqa: E712 if args.session: q = q.filter(Document.session_id == args.session) q = q.order_by(Document.updated_at.desc()).limit(args.limit) emit([_serialize(d) for d in q.all()], args) finally: db.close() def cmd_show(args): db = SessionLocal() try: d = db.get(Document, args.id) if not d: fail(f"no document with id {args.id!r}") emit(_serialize(d, include_content=True), args) finally: db.close() def cmd_versions(args): db = SessionLocal() try: d = db.get(Document, args.id) if not d: fail(f"no document with id {args.id!r}") rows = db.query(DocumentVersion).filter( DocumentVersion.document_id == args.id ).order_by(DocumentVersion.version_number.desc()).all() emit([ { "version_number": v.version_number, "summary": v.summary or "", "source": v.source or "ai", "content_length": _text_len(v.content), } for v in rows ], args) finally: db.close() def cmd_export(args): """Print the full content of a specific version as the JSON payload's `content` field, or raw text via --raw.""" db = SessionLocal() try: d = db.get(Document, args.id) if not d: fail(f"no document with id {args.id!r}") if args.version is None: content = d.current_content or "" version = d.version_count or 1 else: v = db.query(DocumentVersion).filter( DocumentVersion.document_id == args.id, DocumentVersion.version_number == args.version, ).first() if not v: fail(f"no version {args.version} on doc {args.id!r}") content = v.content or "" version = v.version_number if args.raw: sys.stdout.write(content) if not content.endswith("\n"): sys.stdout.write("\n") return emit({ "id": d.id, "title": d.title, "version": version, "content": content, }, args) finally: db.close() def cmd_search(args): db = SessionLocal() try: like = f"%{args.query}%" rows = db.query(Document).filter( (Document.title.ilike(like)) | (Document.current_content.ilike(like)) ).order_by(Document.updated_at.desc()).limit(args.limit).all() emit([_serialize(d) for d in rows], args) finally: db.close() def cmd_delete(args): db = SessionLocal() try: d = db.get(Document, args.id) if not d: fail(f"no document with id {args.id!r}") d.is_active = False db.commit() emit({"ok": True, "id": d.id, "soft_deleted": True}, args) finally: db.close() def _build_parser(): common = argparse.ArgumentParser(add_help=False) common.add_argument("--pretty", action="store_true") p = argparse.ArgumentParser(prog="odysseus-docs", parents=[common]) sub = p.add_subparsers(dest="cmd", required=True) pl = sub.add_parser("list", parents=[common]) pl.add_argument("--active", action="store_true", help="only is_active=True") pl.add_argument("--session", help="filter by session_id") pl.add_argument("--limit", type=int, default=50) pl.set_defaults(func=cmd_list) psh = sub.add_parser("show", parents=[common]) psh.add_argument("id") psh.set_defaults(func=cmd_show) pv = sub.add_parser("versions", parents=[common]) pv.add_argument("id") pv.set_defaults(func=cmd_versions) pe = sub.add_parser("export", parents=[common]) pe.add_argument("id") pe.add_argument("--version", type=int, help="specific version (default: current)") pe.add_argument("--raw", action="store_true", help="write raw content to stdout") pe.set_defaults(func=cmd_export) ps = sub.add_parser("search", parents=[common]) ps.add_argument("query") ps.add_argument("--limit", type=int, default=50) ps.set_defaults(func=cmd_search) pd = sub.add_parser("delete", parents=[common]) pd.add_argument("id") pd.set_defaults(func=cmd_delete) return p if __name__ == "__main__": sys.exit(run(_build_parser()))