diff --git a/.gitignore b/.gitignore index b3d0195dd..8d171d6c4 100644 --- a/.gitignore +++ b/.gitignore @@ -111,4 +111,7 @@ assets/gen_typing_gif.py !/docs/ /docs/* !/docs/hub/ - +!/notebooklm/ +/notebooklm/* +/notebooklm/.* +!/notebooklm/agent-harness/ diff --git a/README.md b/README.md index 73ad156d3..557e30495 100644 --- a/README.md +++ b/README.md @@ -708,6 +708,8 @@ cli-anything/ ├── 🧜 mermaid/agent-harness/ # Mermaid Live Editor CLI (10 tests) ├── ✨ anygen/agent-harness/ # AnyGen CLI (50 tests) ├── 🖼️ comfyui/agent-harness/ # ComfyUI CLI (70 tests) +├── 🧠 notebooklm/agent-harness/ # NotebookLM CLI (experimental, 21 tests) +├── 🖼️ comfyui/agent-harness/ # ComfyUI CLI (70 tests) └── 🛡️ adguardhome/agent-harness/ # AdGuardHome CLI (36 tests) ``` diff --git a/notebooklm/agent-harness/ATTRIBUTION.md b/notebooklm/agent-harness/ATTRIBUTION.md new file mode 100644 index 000000000..bb27d4bcb --- /dev/null +++ b/notebooklm/agent-harness/ATTRIBUTION.md @@ -0,0 +1,29 @@ +# Attribution + +This harness adapts the CLI-Anything methodology for NotebookLM. + +## Acknowledgements + +- **CLI-Anything** + - Project: https://github.com/HKUDS/CLI-Anything + - Methodology: https://github.com/HKUDS/CLI-Anything/blob/main/cli-anything-plugin/HARNESS.md + - We follow its agent-native CLI conventions, including REPL-first design, JSON output, package layout, and test documentation patterns. + +- **notebooklm-py** + - Project: https://github.com/teng-lin/notebooklm-py + - PyPI: https://pypi.org/project/notebooklm-py/ + - This harness is designed to interoperate with the installed `notebooklm` CLI distributed by notebooklm-py. + +- **Google NotebookLM** + - Product help: https://support.google.com/notebooklm/answer/16206563 + - NotebookLM is a Google product. This harness is unofficial and not affiliated with or endorsed by Google. + +## Design Boundary + +This project prefers composition over copying: + +- wrap the installed `notebooklm` CLI +- document upstream dependencies and policies +- avoid vendoring third-party NotebookLM implementation code into this repository + +If you extend this harness, preserve these acknowledgements and keep the unofficial / experimental disclaimer intact. diff --git a/notebooklm/agent-harness/NOTEBOOKLM.md b/notebooklm/agent-harness/NOTEBOOKLM.md new file mode 100644 index 000000000..c53731dab --- /dev/null +++ b/notebooklm/agent-harness/NOTEBOOKLM.md @@ -0,0 +1,44 @@ +# NotebookLM: Project-Specific Analysis & SOP + +## Architecture Summary + +NotebookLM is a hosted Google research and content-generation product. Unlike the +local GUI applications that CLI-Anything usually targets, this harness wraps an +installed `notebooklm` command-line client that already manages authentication, +source ingestion, chat, artifact generation, and downloads. + +This harness therefore behaves as a **service-style CLI wrapper**: + +1. Resolve the local `notebooklm` executable. +2. Build explicit commands with notebook context where needed. +3. Sanitize sensitive auth-related error output. +4. Persist lightweight local session state for REPL convenience. + +## Backend Strategy + +- Prefer the installed `notebooklm` CLI over reimplementing NotebookLM internals. +- Keep credentials outside the repository and outside test fixtures. +- Treat this integration as experimental and unofficial. +- Require explicit confirmation for destructive or high-impact operations. +- Preserve clear attribution to CLI-Anything, notebooklm-py, and Google NotebookLM documentation. + +## Contribution Boundary + +This harness is designed to be safe for an upstream contribution review: + +- it wraps an installed community CLI instead of vendoring third-party NotebookLM code +- it documents copyright and service-boundary concerns instead of implying official support +- it limits automated verification to non-destructive smoke coverage unless a local authenticated session is intentionally used +- it keeps end-to-end authenticated testing manual so secrets and account state stay out of CI and fixtures + +## Constraints + +- Depends on a valid local Google-authenticated NotebookLM session. +- Depends on behavior provided by the installed `notebooklm` CLI. +- Full authenticated end-to-end tests are manual by design. + +## References + +- CLI-Anything methodology: https://github.com/HKUDS/CLI-Anything +- notebooklm-py project: https://github.com/teng-lin/notebooklm-py +- Google NotebookLM help: https://support.google.com/notebooklm/answer/16206563 diff --git a/notebooklm/agent-harness/THIRD_PARTY_NOTICES.md b/notebooklm/agent-harness/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..dea35669f --- /dev/null +++ b/notebooklm/agent-harness/THIRD_PARTY_NOTICES.md @@ -0,0 +1,26 @@ +# Third-Party Notices + +## notebooklm-py + +- Homepage: https://github.com/teng-lin/notebooklm-py +- PyPI: https://pypi.org/project/notebooklm-py/ +- License: MIT + +This harness is intended to work with the `notebooklm` CLI provided by notebooklm-py. + +## Click + +Used for CLI command structure. + +## prompt-toolkit + +Used for REPL interaction helpers. + +## Important Boundary + +Google NotebookLM is a third-party online service. This harness is an unofficial +integration layer and is not affiliated with Google. Users are responsible for: + +- complying with Google terms and product restrictions +- protecting their own credentials and local auth state +- respecting copyright for imported sources diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/README.md b/notebooklm/agent-harness/cli_anything/notebooklm/README.md new file mode 100644 index 000000000..88fff930f --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/README.md @@ -0,0 +1,112 @@ +# CLI-Anything NotebookLM Harness + +Experimental NotebookLM harness for CLI-Anything. + +This package wraps an installed `notebooklm` CLI and exposes a CLI-Anything-style +interface for authentication checks, notebook selection, source management, chat, +artifact generation, downloads, and sharing. + +## Status + +- Experimental +- Community-maintained +- Unofficial and not affiliated with Google + +## Requirements + +- Python 3.10+ +- An installed `notebooklm` command +- A valid local NotebookLM login session + +## Install + +```bash +cd notebooklm/agent-harness +python3 -m pip install -e . +``` + +If the upstream NotebookLM CLI is not installed yet: + +```bash +python3 -m pip install --user 'notebooklm-py[browser]' +python3 -m playwright install chromium +``` + +## Run + +```bash +# Show help +cli-anything-notebooklm --help + +# Check auth state (wraps `notebooklm auth check`) +cli-anything-notebooklm auth status + +# List notebooks +cli-anything-notebooklm notebook list +``` + +## Run Tests + +```bash +cd notebooklm/agent-harness +python3 -m pytest cli_anything/notebooklm/tests -q +python3 -m cli_anything.notebooklm.notebooklm_cli --help +``` + +## Command Groups + +| Group | Purpose | +| --- | --- | +| `auth` | login helpers and authentication checks | +| `notebook` | list, create, and summarize notebooks | +| `source` | inspect sources and add URL sources | +| `chat` | ask questions and inspect history | +| `artifact` | list artifacts and generate reports | +| `download` | download generated artifacts | +| `share` | inspect sharing status | + +## Common Workflows + +```bash +cli-anything-notebooklm auth status +cli-anything-notebooklm notebook list +cli-anything-notebooklm source list --notebook nb_123 +cli-anything-notebooklm chat ask "Summarize the current notebook" +cli-anything-notebooklm artifact generate-report --notebook nb_123 +``` + +## For AI Agents + +- Prefer explicit notebook IDs with `--notebook` instead of relying on ambient state. +- Use `--json` only on commands whose upstream `notebooklm` subcommand supports machine-readable output. +- Treat NotebookLM auth state as sensitive local data and never print cookie or storage files. +- Treat this harness as a thin wrapper around `notebooklm`, not a reimplementation of NotebookLM. + +## Acknowledgements + +This harness is inspired by the CLI-Anything methodology: +https://github.com/HKUDS/CLI-Anything + +It is designed to work with the community-maintained `notebooklm` CLI from `notebooklm-py`: +https://github.com/teng-lin/notebooklm-py + +NotebookLM is a Google product: +https://support.google.com/notebooklm/answer/16206563 + +This project is unofficial and not affiliated with Google. + +## Safety Notes + +- Do not commit local auth state into the repository. +- Do not upload sensitive content without permission. +- Respect copyright and service terms for imported sources. +- Prefer review-oriented or read-only commands first when working inside a live notebook. +- Treat sharing and artifact generation as user-impacting operations that deserve explicit intent. + +## References + +- CLI-Anything: https://github.com/HKUDS/CLI-Anything +- CLI-Anything HARNESS.md: https://github.com/HKUDS/CLI-Anything/blob/main/cli-anything-plugin/HARNESS.md +- notebooklm-py: https://github.com/teng-lin/notebooklm-py +- notebooklm-py on PyPI: https://pypi.org/project/notebooklm-py/ +- Google NotebookLM help: https://support.google.com/notebooklm/answer/16206563 diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/__init__.py b/notebooklm/agent-harness/cli_anything/notebooklm/__init__.py new file mode 100644 index 000000000..c8601e771 --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/__init__.py @@ -0,0 +1,3 @@ +"""NotebookLM harness package.""" + +__version__ = "0.1.0" diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/__main__.py b/notebooklm/agent-harness/cli_anything/notebooklm/__main__.py new file mode 100644 index 000000000..fd1753810 --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/__main__.py @@ -0,0 +1,7 @@ +"""Module entry point for cli-anything-notebooklm.""" + +from cli_anything.notebooklm.notebooklm_cli import main + + +if __name__ == "__main__": + main() diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/core/__init__.py b/notebooklm/agent-harness/cli_anything/notebooklm/core/__init__.py new file mode 100644 index 000000000..2c7cef76d --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/core/__init__.py @@ -0,0 +1 @@ +"""Core modules for the NotebookLM harness.""" diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/core/session.py b/notebooklm/agent-harness/cli_anything/notebooklm/core/session.py new file mode 100644 index 000000000..995e6b01c --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/core/session.py @@ -0,0 +1,42 @@ +"""Session persistence helpers for NotebookLM CLI context.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +class Session: + """Persist the active notebook for REPL and one-shot commands.""" + + def __init__(self, session_file: str | Path | None = None): + if session_file is None: + session_file = Path.home() / ".cli-anything-notebooklm" / "session.json" + self.session_file = Path(session_file) + self.session_file.parent.mkdir(parents=True, exist_ok=True) + self._data = self._load() + + def _load(self) -> dict: + if not self.session_file.exists(): + return {"active_notebook": None} + try: + return json.loads(self.session_file.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {"active_notebook": None} + + def _save(self): + self.session_file.write_text( + json.dumps(self._data, indent=2, ensure_ascii=True), + encoding="utf-8", + ) + + def get_active_notebook(self) -> str | None: + return self._data.get("active_notebook") + + def set_active_notebook(self, notebook_id: str): + self._data["active_notebook"] = notebook_id + self._save() + + def clear_active_notebook(self): + self._data["active_notebook"] = None + self._save() diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/notebooklm_cli.py b/notebooklm/agent-harness/cli_anything/notebooklm/notebooklm_cli.py new file mode 100644 index 000000000..5da8612e6 --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/notebooklm_cli.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""NotebookLM CLI — Experimental NotebookLM wrapper for AI agents.""" + +from __future__ import annotations + +import json +import sys + +import click + +from cli_anything.notebooklm import __version__ +from cli_anything.notebooklm.core.session import Session +from cli_anything.notebooklm.utils.notebooklm_backend import run_notebooklm + +_json_output = False +_session: Session | None = None + + +def get_session() -> Session: + global _session + if _session is None: + _session = Session() + return _session + + +def emit(data, message: str = ""): + if _json_output: + click.echo(json.dumps(data, indent=2, default=str)) + elif message: + click.echo(message) + elif isinstance(data, str): + click.echo(data) + else: + click.echo(json.dumps(data, indent=2, default=str)) + + +def handle_error(func): + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as exc: # pragma: no cover - scaffold behavior + if _json_output: + click.echo(json.dumps({"error": str(exc), "type": type(exc).__name__})) + else: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + return wrapper + + +def resolve_notebook_id(notebook_id: str | None) -> str | None: + return notebook_id or get_session().get_active_notebook() + + +@click.group(invoke_without_command=True) +@click.option("--json", "use_json", is_flag=True, help="Output as JSON") +@click.option("--notebook", "notebook_id", default=None, help="Active notebook ID") +@click.pass_context +def cli(ctx, use_json, notebook_id): + """NotebookLM CLI — Experimental NotebookLM wrapper for AI agents.""" + global _json_output + _json_output = use_json + if notebook_id: + get_session().set_active_notebook(notebook_id) + if ctx.invoked_subcommand is None: + ctx.invoke(repl) + + +@cli.command() +def repl(): + """Start a minimal REPL placeholder.""" + click.echo(f"cli-anything-notebooklm v{__version__}") + click.echo("Experimental harness scaffold. Use --help to inspect command groups.") + + +@cli.group() +def auth(): + """Authentication and login helpers.""" + + +@auth.command("status") +@handle_error +def auth_status(): + """Check authentication status.""" + emit(run_notebooklm(["auth", "check"], json_output=_json_output)) + + +@auth.command("login") +@handle_error +def auth_login(): + """Open the browser login flow.""" + emit(run_notebooklm(["login"], json_output=_json_output)) + + +@auth.command("check") +@handle_error +def auth_check(): + """Run a lightweight authentication check.""" + emit(run_notebooklm(["auth", "check"], json_output=_json_output)) + + +@cli.group() +def notebook(): + """Notebook management commands.""" + + +@notebook.command("list") +@handle_error +def notebook_list(): + """List notebooks.""" + emit(run_notebooklm(["list"], json_output=_json_output)) + + +@notebook.command("create") +@click.argument("name") +@handle_error +def notebook_create(name): + """Create a notebook.""" + emit(run_notebooklm(["create", name], json_output=_json_output)) + + +@notebook.command("summary") +@handle_error +def notebook_summary(): + """Summarize the active notebook.""" + emit( + run_notebooklm( + ["summary"], + notebook_id=resolve_notebook_id(None), + json_output=_json_output, + ) + ) + + +@cli.group() +def source(): + """Source ingestion and inspection commands.""" + + +@source.command("list") +@click.option("--notebook", "notebook_id", default=None, help="Notebook ID") +@handle_error +def source_list(notebook_id): + """List sources for a notebook.""" + emit( + run_notebooklm( + ["source", "list"], + notebook_id=resolve_notebook_id(notebook_id), + json_output=_json_output, + ) + ) + + +@source.command("add-url") +@click.argument("url") +@click.option("--notebook", "notebook_id", default=None, help="Notebook ID") +@handle_error +def source_add_url(url, notebook_id): + """Add a URL source.""" + emit( + run_notebooklm( + ["source", "add", url], + notebook_id=resolve_notebook_id(notebook_id), + json_output=_json_output, + ) + ) + + +@cli.group() +def chat(): + """Chat and history commands.""" + + +@chat.command("ask") +@click.argument("prompt") +@click.option("--notebook", "notebook_id", default=None, help="Notebook ID") +@handle_error +def chat_ask(prompt, notebook_id): + """Ask a question against a notebook.""" + emit( + run_notebooklm( + ["ask", prompt], + notebook_id=resolve_notebook_id(notebook_id), + json_output=_json_output, + ) + ) + + +@chat.command("history") +@click.option("--notebook", "notebook_id", default=None, help="Notebook ID") +@handle_error +def chat_history(notebook_id): + """Show chat history.""" + emit( + run_notebooklm( + ["history"], + notebook_id=resolve_notebook_id(notebook_id), + json_output=_json_output, + ) + ) + + +@cli.group() +def artifact(): + """Artifact generation and inspection commands.""" + + +@artifact.command("list") +@click.option("--notebook", "notebook_id", default=None, help="Notebook ID") +@handle_error +def artifact_list(notebook_id): + """List notebook artifacts.""" + emit( + run_notebooklm( + ["artifact", "list"], + notebook_id=resolve_notebook_id(notebook_id), + json_output=_json_output, + ) + ) + + +@artifact.command("generate-report") +@click.option("--notebook", "notebook_id", default=None, help="Notebook ID") +@handle_error +def artifact_generate_report(notebook_id): + """Generate a report artifact.""" + emit( + run_notebooklm( + ["generate", "report", "--wait"], + notebook_id=resolve_notebook_id(notebook_id), + json_output=_json_output, + ) + ) + + +@cli.group() +def download(): + """Artifact download helpers.""" + + +@download.command("report") +@click.argument("output_path") +@click.option("--notebook", "notebook_id", default=None, help="Notebook ID") +@handle_error +def download_report(output_path, notebook_id): + """Download the latest report artifact.""" + emit( + run_notebooklm( + ["download", "report", output_path], + notebook_id=resolve_notebook_id(notebook_id), + json_output=_json_output, + ) + ) + + +@cli.group() +def share(): + """Sharing and access control commands.""" + + +@share.command("status") +@click.option("--notebook", "notebook_id", default=None, help="Notebook ID") +@handle_error +def share_status(notebook_id): + """Inspect notebook sharing state.""" + emit( + run_notebooklm( + ["share", "status"], + notebook_id=resolve_notebook_id(notebook_id), + json_output=_json_output, + ) + ) + + +def main(): + cli() + + +if __name__ == "__main__": + main() diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/skills/SKILL.md b/notebooklm/agent-harness/cli_anything/notebooklm/skills/SKILL.md new file mode 100644 index 000000000..12940c3a7 --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/skills/SKILL.md @@ -0,0 +1,78 @@ +--- +name: cli-anything-notebooklm +description: Experimental NotebookLM harness for listing notebooks, managing sources, asking questions, generating artifacts, and downloading outputs through an installed notebooklm CLI. +--- + +# cli-anything-notebooklm + +Experimental NotebookLM harness for CLI-Anything. + +## Installation + +This package is intended to be installed from the harness directory: + +```bash +cd notebooklm/agent-harness +python3 -m pip install -e . +``` + +Install the upstream NotebookLM CLI if needed: + +```bash +python3 -m pip install --user 'notebooklm-py[browser]' +python3 -m playwright install chromium +``` + +## Requirements + +- `notebooklm` command installed locally +- Valid local NotebookLM login session + +## Usage + +### Basic Commands + +```bash +# Show help +cli-anything-notebooklm --help + +# Start with a notebook context +cli-anything-notebooklm --notebook nb_123 source list + +# Prefer JSON for agent use +cli-anything-notebooklm --json notebook list +``` + +## Command Groups + +| Group | Purpose | +| --- | --- | +| `auth` | login and auth validation | +| `notebook` | notebook list, create, summary | +| `source` | source listing and URL add | +| `chat` | ask questions and inspect history | +| `artifact` | list and generate artifacts | +| `download` | fetch generated outputs | +| `share` | inspect sharing state | + +## Agent Workflow + +1. Check auth with `cli-anything-notebooklm auth status` +2. Discover notebook IDs with `cli-anything-notebooklm --json notebook list` +3. Use explicit `--notebook` for follow-up commands +4. Prefer `--json` only where the upstream `notebooklm` command supports it + +## Agent Guidance + +- Prefer explicit notebook IDs with `--notebook`. +- Use `--json` for machine-readable output only on commands that support it upstream. +- Treat this harness as experimental and unofficial. +- Do not expose auth files or cookies in logs. +- NotebookLM is a Google product; this harness is unofficial and not affiliated with Google. + +## References + +- CLI-Anything: https://github.com/HKUDS/CLI-Anything +- CLI-Anything HARNESS.md: https://github.com/HKUDS/CLI-Anything/blob/main/cli-anything-plugin/HARNESS.md +- notebooklm-py: https://github.com/teng-lin/notebooklm-py +- Google NotebookLM help: https://support.google.com/notebooklm/answer/16206563 diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/tests/TEST.md b/notebooklm/agent-harness/cli_anything/notebooklm/tests/TEST.md new file mode 100644 index 000000000..24b37f86a --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/tests/TEST.md @@ -0,0 +1,89 @@ +# NotebookLM Harness - Test Documentation + +## Test Inventory + +| File | Focus | +| --- | --- | +| `test_core.py` | backend discovery, command building, session persistence, packaging fixtures | +| `test_cli_smoke.py` | help output and command group exposure | +| `test_manual_e2e.md` | authenticated local smoke-test checklist | + +## Coverage Notes + +This harness has three validation layers: + +- unit tests for backend helpers and session state +- smoke tests for CLI help and command registration +- manual authenticated end-to-end verification + +Because NotebookLM depends on a live Google account session and an installed +community CLI, full authenticated E2E coverage is not run in public CI. + +## Local Verification + +Verified on 2026-03-17 in the notebooklm harness worktree. + +### Commands Run + +```bash +python3 -m pytest cli_anything/notebooklm/tests/test_core.py -q +python3 -m pytest cli_anything/notebooklm/tests/test_cli_smoke.py -q +python3 -m cli_anything.notebooklm.notebooklm_cli --help +``` + +### Results + +- `test_core.py`: 9 passed +- `test_cli_smoke.py`: 5 passed +- `python3 -m cli_anything.notebooklm.notebooklm_cli --help`: exit code 0, help text rendered correctly + +### Notes + +- Added a regression test for module execution so `python -m cli_anything.notebooklm.notebooklm_cli --help` is covered, not just Click's in-process `CliRunner`. +- The authenticated `notebooklm` backend remains intentionally manual for end-to-end verification because it depends on a local Google session. + +## PR Polish Verification + +Verified on 2026-03-17 after README, SKILL, and subprocess smoke coverage upgrades. + +### Commands Run + +```bash +python3 -m pytest cli_anything/notebooklm/tests -q +python3 -m cli_anything.notebooklm.notebooklm_cli --help +``` + +### Results + +- Full NotebookLM harness suite: 17 passed +- Module help command: exit code 0, rendered command groups correctly + +### Notes + +- Added doc-level assertions so the package README and skill file now explicitly cover installation, tests, safety boundaries, and unofficial Google attribution. +- Added `_resolve_cli`-style subprocess smoke coverage so the CLI can be exercised through a resolved command path, not only through Click's in-process test runner. + +## Review Fix Verification + +Verified on 2026-03-17 after addressing PR review feedback about JSON passthrough and auth command semantics. + +### Commands Run + +```bash +python3 -m pytest cli_anything/notebooklm/tests/test_core.py -q +python3 -m pytest cli_anything/notebooklm/tests/test_cli_smoke.py -q +python3 -m pytest cli_anything/notebooklm/tests -q +python3 -m cli_anything.notebooklm.notebooklm_cli --json auth login +``` + +### Results + +- targeted backend tests: pass +- targeted CLI routing tests: pass +- full NotebookLM harness suite: pass +- `--json auth login`: fails fast with a structured JSON error instead of passing an invalid `--json` flag through to upstream login + +### Notes + +- `auth status` now wraps upstream `notebooklm auth check`, which matches authentication semantics. +- JSON passthrough is now limited to wrapper commands whose upstream `notebooklm` command has verified `--json` support. diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/tests/__init__.py b/notebooklm/agent-harness/cli_anything/notebooklm/tests/__init__.py new file mode 100644 index 000000000..9eb399dd0 --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the NotebookLM harness.""" diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/tests/test_cli_smoke.py b/notebooklm/agent-harness/cli_anything/notebooklm/tests/test_cli_smoke.py new file mode 100644 index 000000000..d803d5758 --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/tests/test_cli_smoke.py @@ -0,0 +1,105 @@ +"""CLI smoke tests for the NotebookLM harness scaffold.""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys + +from click.testing import CliRunner + +from cli_anything.notebooklm.notebooklm_cli import cli + + +def _resolve_cli(name): + """Resolve installed CLI command; fall back to python -m for local dev.""" + force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1" + path = shutil.which(name) + if path: + return [path], None + if force: + raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .") + + module = "cli_anything.notebooklm.notebooklm_cli" + package_root = Path(__file__).resolve().parents[3] + env = os.environ.copy() + current = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = ( + f"{package_root}{os.pathsep}{current}" if current else str(package_root) + ) + return [sys.executable, "-m", module], env + + +class TestRootHelp: + def test_root_help_shows_experimental_notebooklm(self): + result = CliRunner().invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "NotebookLM CLI" in result.output + assert "Experimental" in result.output + + def test_root_help_lists_command_groups(self): + result = CliRunner().invoke(cli, ["--help"]) + assert result.exit_code == 0 + for group in ["auth", "notebook", "source", "chat", "artifact", "download", "share"]: + assert group in result.output + + +class TestSubcommandHelp: + def test_auth_status_help(self): + result = CliRunner().invoke(cli, ["auth", "status", "--help"]) + assert result.exit_code == 0 + assert "Check authentication status" in result.output + + def test_notebook_list_help(self): + result = CliRunner().invoke(cli, ["notebook", "list", "--help"]) + assert result.exit_code == 0 + assert "List notebooks" in result.output + + +class TestCommandRouting: + def test_auth_status_routes_to_auth_check(self, monkeypatch): + calls = [] + + def fake_run(args, **kwargs): + calls.append((args, kwargs)) + return {"ok": True} + + monkeypatch.setattr( + "cli_anything.notebooklm.notebooklm_cli.run_notebooklm", + fake_run, + ) + + result = CliRunner().invoke(cli, ["auth", "status"]) + + assert result.exit_code == 0 + assert calls == [(["auth", "check"], {"json_output": False})] + + +class TestModuleExecution: + def test_python_m_module_help_emits_output(self): + result = subprocess.run( + [ + sys.executable, + "-m", + "cli_anything.notebooklm.notebooklm_cli", + "--help", + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert "NotebookLM CLI" in result.stdout + + def test_resolved_cli_help_emits_output(self): + command, env = _resolve_cli("cli-anything-notebooklm") + result = subprocess.run( + command + ["--help"], + capture_output=True, + text=True, + check=False, + env=env, + ) + assert result.returncode == 0 + assert "NotebookLM CLI" in result.stdout + assert "auth" in result.stdout diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/tests/test_core.py b/notebooklm/agent-harness/cli_anything/notebooklm/tests/test_core.py new file mode 100644 index 000000000..bc9d2f770 --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/tests/test_core.py @@ -0,0 +1,112 @@ +"""Unit tests for NotebookLM harness scaffold.""" + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cli_anything.notebooklm.core.session import Session +from cli_anything.notebooklm.utils.notebooklm_backend import ( + build_command, + command_supports_json, + require_notebooklm, + run_notebooklm, + sanitize_error, +) + + +class TestBackendDiscovery: + def test_require_notebooklm_returns_path(self): + with patch("cli_anything.notebooklm.utils.notebooklm_backend.shutil.which", return_value="/usr/local/bin/notebooklm"): + assert require_notebooklm() == "/usr/local/bin/notebooklm" + + def test_require_notebooklm_raises_with_install_guidance(self): + with patch("cli_anything.notebooklm.utils.notebooklm_backend.shutil.which", return_value=None): + with pytest.raises(RuntimeError, match="notebooklm command not found"): + require_notebooklm() + + +class TestCommandBuilder: + def test_build_command_with_notebook_id_and_json(self): + command = build_command( + ["source", "list"], + notebook_id="nb_123", + json_output=True, + ) + assert command == [ + "notebooklm", + "source", + "list", + "-n", + "nb_123", + "--json", + ] + + def test_build_command_without_notebook_id(self): + command = build_command(["list"]) + assert command == ["notebooklm", "list"] + + def test_command_supports_json_for_verified_command(self): + assert command_supports_json(["download", "report"]) is True + + def test_command_supports_json_rejects_unsupported_command(self): + assert command_supports_json(["login"]) is False + + +class TestRunNotebooklm: + def test_run_notebooklm_rejects_json_for_unsupported_command(self): + with patch("cli_anything.notebooklm.utils.notebooklm_backend.subprocess.run") as run_mock: + with pytest.raises(RuntimeError, match="JSON output is not supported for command: login"): + run_notebooklm(["login"], json_output=True) + run_mock.assert_not_called() + + +class TestErrorSanitization: + def test_sanitize_error_redacts_storage_state_path(self): + raw = "Failed to open /Users/tester/.notebooklm/storage_state.json because auth expired" + assert "storage_state.json" not in sanitize_error(raw) + assert "[redacted-auth-path]" in sanitize_error(raw) + + +class TestSession: + def test_session_persists_active_notebook(self, tmp_path): + session_file = tmp_path / "session.json" + session = Session(session_file=session_file) + session.set_active_notebook("nb_abc") + + reloaded = Session(session_file=session_file) + assert reloaded.get_active_notebook() == "nb_abc" + + def test_session_clear_active_notebook(self, tmp_path): + session_file = tmp_path / "session.json" + session = Session(session_file=session_file) + session.set_active_notebook("nb_abc") + session.clear_active_notebook() + + data = json.loads(Path(session_file).read_text()) + assert data["active_notebook"] is None + + +class TestPackagingFixtures: + def test_acknowledgements_reference_external_projects(self): + readme = Path("cli_anything/notebooklm/README.md").read_text(encoding="utf-8") + assert "CLI-Anything" in readme + assert "notebooklm-py" in readme + + def test_readme_documents_install_test_and_safety_sections(self): + readme = Path("cli_anything/notebooklm/README.md").read_text(encoding="utf-8") + assert "## Install" in readme + assert "## Run Tests" in readme + assert "## Safety Notes" in readme + assert "Google NotebookLM" in readme + + def test_skill_file_contains_usage_and_boundary_guidance(self): + skill = Path("cli_anything/notebooklm/skills/SKILL.md").read_text(encoding="utf-8") + assert "## Installation" in skill + assert "## Usage" in skill + assert "unofficial" in skill.lower() + assert "not affiliated with Google" in skill + + def test_skill_file_exists(self): + assert Path("cli_anything/notebooklm/skills/SKILL.md").is_file() diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/tests/test_manual_e2e.md b/notebooklm/agent-harness/cli_anything/notebooklm/tests/test_manual_e2e.md new file mode 100644 index 000000000..c0b943dd9 --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/tests/test_manual_e2e.md @@ -0,0 +1,11 @@ +# Manual E2E Checklist + +1. Run `notebooklm login`. +2. Run `cli-anything-notebooklm auth status`. +3. Run `cli-anything-notebooklm notebook list`. +4. Create a temporary notebook with `cli-anything-notebooklm notebook create "CLI Anything Smoke Test"`. +5. Add a simple text or URL source. +6. Run `cli-anything-notebooklm chat ask "Summarize the notebook."`. +7. Generate one artifact, such as a report. +8. Download the artifact to a temporary local path. +9. Delete the temporary notebook manually after verification. diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/utils/__init__.py b/notebooklm/agent-harness/cli_anything/notebooklm/utils/__init__.py new file mode 100644 index 000000000..2f7adc5be --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for the NotebookLM harness.""" diff --git a/notebooklm/agent-harness/cli_anything/notebooklm/utils/notebooklm_backend.py b/notebooklm/agent-harness/cli_anything/notebooklm/utils/notebooklm_backend.py new file mode 100644 index 000000000..6603d980f --- /dev/null +++ b/notebooklm/agent-harness/cli_anything/notebooklm/utils/notebooklm_backend.py @@ -0,0 +1,124 @@ +"""NotebookLM backend adapter. + +This module wraps an installed `notebooklm` CLI for use inside a CLI-Anything +harness. It does not implement a Google official API client. + +References: +- CLI-Anything methodology: https://github.com/HKUDS/CLI-Anything +- notebooklm-py project: https://github.com/teng-lin/notebooklm-py + +Security rules: +- never print credential files or cookies +- never commit auth state into the repository +- prefer explicit notebook IDs +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess + + +JSON_SUPPORTED_COMMANDS = { + ("auth", "check"), + ("status",), + ("list",), + ("create",), + ("source", "list"), + ("source", "add"), + ("ask",), + ("history",), + ("artifact", "list"), + ("generate", "report"), + ("download", "report"), + ("share", "status"), +} +TWO_PART_COMMAND_GROUPS = {"auth", "source", "artifact", "generate", "download", "share"} + + +def require_notebooklm() -> str: + """Resolve the notebooklm command from PATH.""" + path = shutil.which("notebooklm") + if path: + return path + raise RuntimeError( + "notebooklm command not found. Install it with:\n" + " python3 -m pip install --user 'notebooklm-py[browser]'\n" + " python3 -m playwright install chromium" + ) + + +def command_supports_json(args: list[str]) -> bool: + """Return whether the wrapped notebooklm command supports --json.""" + if not args: + return False + if len(args) > 1 and args[0] in TWO_PART_COMMAND_GROUPS: + key = tuple(args[:2]) + else: + key = tuple(args[:1]) + return key in JSON_SUPPORTED_COMMANDS + + +def build_command( + args: list[str], + *, + notebook_id: str | None = None, + json_output: bool = False, +) -> list[str]: + """Build a notebooklm command with explicit notebook context.""" + command = ["notebooklm", *args] + if notebook_id: + command.extend(["-n", notebook_id]) + if json_output and command_supports_json(args): + command.append("--json") + return command + + +def sanitize_error(text: str) -> str: + """Redact local auth file paths from stderr/stdout.""" + patterns = [ + r"/Users/[^/\s]+/\.notebooklm/storage_state\.json", + r"/home/[^/\s]+/\.notebooklm/storage_state\.json", + r"[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\\.notebooklm\\\\storage_state\.json", + ] + sanitized = text + for pattern in patterns: + sanitized = re.sub(pattern, "[redacted-auth-path]", sanitized) + sanitized = sanitized.replace("storage_state.json", "[redacted-auth-path]") + return sanitized + + +def run_notebooklm( + args: list[str], + *, + notebook_id: str | None = None, + json_output: bool = False, +) -> dict | str: + """Run notebooklm and optionally parse JSON output.""" + if json_output and not command_supports_json(args): + raise RuntimeError( + f"JSON output is not supported for command: {' '.join(args)}" + ) + + command = build_command( + args, + notebook_id=notebook_id, + json_output=json_output, + ) + command[0] = require_notebooklm() + + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + raise RuntimeError(sanitize_error(result.stderr or result.stdout)) + + if json_output: + return json.loads(result.stdout or "{}") + return result.stdout.strip() diff --git a/notebooklm/agent-harness/setup.py b/notebooklm/agent-harness/setup.py new file mode 100644 index 000000000..c6679bd04 --- /dev/null +++ b/notebooklm/agent-harness/setup.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +setup.py for cli-anything-notebooklm + +Install with: pip install -e . +""" + +from setuptools import setup, find_namespace_packages + +with open("cli_anything/notebooklm/README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="cli-anything-notebooklm", + version="0.1.0", + author="cli-anything contributors", + author_email="", + description="Experimental CLI harness for NotebookLM via an installed notebooklm CLI. Unofficial and community-maintained.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/HKUDS/CLI-Anything", + packages=find_namespace_packages(include=["cli_anything.*"]), + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + python_requires=">=3.10", + install_requires=[ + "click>=8.0.0", + "prompt-toolkit>=3.0.0", + ], + extras_require={ + "dev": [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + ], + }, + entry_points={ + "console_scripts": [ + "cli-anything-notebooklm=cli_anything.notebooklm.notebooklm_cli:main", + ], + }, + package_data={ + "cli_anything.notebooklm": ["skills/*.md"], + }, + include_package_data=True, + zip_safe=False, +) diff --git a/registry.json b/registry.json index 86011a3a6..4758c86db 100644 --- a/registry.json +++ b/registry.json @@ -2,7 +2,7 @@ "meta": { "repo": "https://github.com/HKUDS/CLI-Anything", "description": "CLI-Hub — Agent-native stateful CLI interfaces for softwares, codebases, and Web Services", - "updated": "2026-03-17" + "updated": "2026-03-18" }, "clis": [ { @@ -125,6 +125,18 @@ "skill_md": null, "category": "diagrams" }, + { + "name": "notebooklm", + "display_name": "NotebookLM", + "version": "0.1.0", + "description": "Experimental NotebookLM harness scaffold wrapping the installed notebooklm CLI for notebook, source, chat, artifact, download, and sharing workflows", + "requires": "notebooklm CLI from notebooklm-py + valid local NotebookLM login session", + "homepage": "https://notebooklm.google.com", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=notebooklm/agent-harness", + "entry_point": "cli-anything-notebooklm", + "skill_md": "notebooklm/agent-harness/cli_anything/notebooklm/skills/SKILL.md", + "category": "ai" + }, { "name": "obs-studio", "display_name": "OBS Studio",