feat: Add readme and agent command

* maint: suggest uv tool install when appropriate * Add README and AGENT commands (#33) Bundle README.md and AGENTS.md and use importlib.resources

Seth Troisi committed May 28, 2026 at 16:02 UTC cb4b2150e13ab672ffb211f8bdbe9fd51a585af4
7 files changed +197 -6
docs/04_automation_and_utility.md
+21
@@ -1,5 +1,6 @@
1 ---
2 log:
3 +2026-05-27: Refactored `colab README` and `colab AGENT` to bundle `README.md` and `AGENTS.md` via Hatchling's `force-include` and read them using `importlib.resources` instead of `importlib.metadata`. `colab AGENT` now correctly prints `AGENTS.md`.
4 2026-05-27: Extended `colab update --install` to detect if the CLI was installed via `uv tool install` (by checking if `sys.executable` contains `/uv/`) and if so, use `uv tool install -U google-colab-cli` to upgrade.
5 2026-05-27: Updated auto-update upgrade hint to recommend `pip install --upgrade google-colab-cli` instead of `colab`, aligning with the PyPI package name.
6 2026-05-27: `colab url` now emits BOTH the `?dbu=<urlencoded path>` query parameter (existing) AND a new `#datalabBackendUrl=<full URL>` hash fragment (new). Format: `https://<host>/notebooks/empty.ipynb?dbu=%2Ftun%2Fm%2F<endpoint>#datalabBackendUrl=<host>/tun/m/<endpoint>`. Why both: some Colab frontend code paths consult the hash fragment first and ignore `dbu` entirely, so the previously-emitted query-only form failed silently for those users (the frontend fell through to allocating a fresh VM via `/tun/m/assign`). The fragment value is a FULL URL with scheme + host (NOT just the path) and is emitted RAW (no URL encoding) because browsers don't decode the fragment before passing `location.hash` to page JS — Colab's parser calls `new URL(rawString)` directly. The fragment host always matches `--host` so Colab's same-origin enforcement on embedded backend URLs doesn't block the connection, and sandbox/dev users (`--host https://colab.sandbox.google.com`) get a sandbox fragment automatically. Three new test cases in `tests/test_url.py` cover the raw-encoding requirement (`%3A`/`%2F` must NOT appear in the fragment), the both-signals-present invariant, and `--open` propagating the fragment to `webbrowser.open()`. Integration-verified live against synthetic session state with three host shapes (default, sandbox, trailing-slash); all produced correctly-shaped URLs with no `//` artifacts.
@@ -241,6 +242,19 @@ remediation guidance) rather than silently after ~1 minute via the daemon.
242 - openid
243 ```
244
245 +### 9. README and AGENT (`colab README`, `colab AGENT`)
246 +
247 +- **Action**: Print the bundled `README.md` or `AGENTS.md` file.
248 +- **Implementation**:
249 + - Uses `importlib.resources.files("colab_cli").joinpath(...)` to read the
250 + bundled `README.md` (for `colab README`) or `AGENTS.md` (for `colab AGENT`)
251 + from the package resources.
252 + - The files are bundled into the package via Hatchling's `force-include`
253 + configuration in `pyproject.toml`.
254 + - If reading from resources fails (e.g. during development when not
255 + installed), it falls back to reading the files from the project root.
256 + - Prints the content to stdout.
257 +
258 ## Implementation Details
259
260 - **Code Injection**: Use a standard `run_code(session, code)` helper via
@@ -286,3 +300,10 @@ TDD is mandatory for all automation features.
300 - **Test Case**: `creds.refresh()` is called before `creds.token` is read
301 (regression against silently-`None` tokens for service-account /
302 GCE-metadata creds).
303 +
304 +### 4. `README` and `AGENT` Commands
305 +
306 +- **Test Case**: Verify `colab README` prints the expected content when package metadata is available.
307 +- **Test Case**: Verify `colab AGENT` prints the same content.
308 +- **Test Case**: Verify fallback to local `README.md` file when metadata is not available.
309 +- **Test Case**: Verify error exit when both metadata and local file are unavailable.
pyproject.toml
+4
@@ -34,6 +34,10 @@ source = "vcs"
34 [tool.hatch.build.targets.wheel]
35 packages = ["src/colab_cli"]
36
37 +[tool.hatch.build.targets.wheel.force-include]
38 +"README.md" = "colab_cli/README.md"
39 +"AGENTS.md" = "colab_cli/AGENTS.md"
40 +
41 [tool.uv]
42 package = true
43
src/colab_cli/auto_update.py
+14 -5
@@ -23,6 +23,7 @@ callback (``cli.py``) calls ``check_for_updates`` once per day and
23 """
24
25 import json
26 +import platform
27 import subprocess
28 import urllib.request
29 from datetime import datetime, timezone
@@ -35,6 +36,9 @@ import typer
36 from colab_cli.common import state
37 from colab_cli.state import Settings
38
39 +# PyPI distribution name (different from the importable package name `colab`).
40 +PYPI_PACKAGE_NAME = "google-colab-cli"
41 +
42
43 # ---------- Version detection -------------------------------------------
44
@@ -122,6 +126,15 @@ def announce_upgrade(
126 # ---------- Orchestration -----------------------------------------------
127
128
129 +def _get_install_command() -> str:
130 + """Return the recommended installation command based on the environment."""
131 + import sys
132 +
133 + if platform.system() == "Linux" and "/uv/tools/" in sys.executable:
134 + return f"uv tool install -U {PYPI_PACKAGE_NAME}"
135 + return f"pip install --upgrade {PYPI_PACKAGE_NAME}"
136 +
137 +
138 def check_for_updates(quiet: bool = False) -> None:
139 """Check PyPI for updates and print a message if a new version is available.
140
@@ -140,7 +153,7 @@ def check_for_updates(quiet: bool = False) -> None:
153 announce_upgrade(
154 pypi_v,
155 current,
143 - "pip install --upgrade google-colab-cli",
156 + _get_install_command(),
157 show_disable_hint=quiet,
158 )
159 elif not quiet:
@@ -211,10 +224,6 @@ def run_background_check() -> None:
224 # ---------- Self-install ------------------------------------------------
225
226
214 -# PyPI distribution name (different from the importable package name `colab`).
215 -PYPI_PACKAGE_NAME = "google-colab-cli"
216 -
217 -
227 def self_install() -> None:
228 """Upgrade the CLI in place, detecting uv vs pip."""
229 import sys
src/colab_cli/cli.py
+2
@@ -105,6 +105,8 @@ def callback(
105 "help",
106 "url",
107 "whoami",
108 + "README",
109 + "AGENT",
110 }
111 if ctx.invoked_subcommand not in _AUTO_UPDATE_SUPPRESSED:
112 auto_update.run_background_check()
src/colab_cli/commands/utility.py
+46
@@ -379,6 +379,48 @@ def update_command(
379 auto_update.self_install()
380
381
382 +def _print_resource(filename: str) -> None:
383 + import importlib.resources
384 + import os
385 +
386 + content = None
387 + try:
388 + # Try reading from package resources
389 + ref = importlib.resources.files("colab_cli").joinpath(filename)
390 + if ref.is_file():
391 + content = ref.read_text(encoding="utf-8")
392 + except Exception:
393 + pass
394 +
395 + if not content:
396 + # Fallback to local file for development
397 + local_path = os.path.abspath(
398 + os.path.join(os.path.dirname(__file__), f"../../../{filename}")
399 + )
400 + if os.path.exists(local_path):
401 + try:
402 + with open(local_path, "r", encoding="utf-8") as f:
403 + content = f.read()
404 + except Exception:
405 + pass
406 +
407 + if content:
408 + typer.echo(content)
409 + else:
410 + typer.echo(f"[colab] {filename} content not available.", err=True)
411 + raise typer.Exit(code=1)
412 +
413 +
414 +def readme():
415 + """Print the bundled README.md file"""
416 + _print_resource("README.md")
417 +
418 +
419 +def agent():
420 + """Print the bundled AGENTS.md file"""
421 + _print_resource("AGENTS.md")
422 +
423 +
424 def register(app: typer.Typer):
425 app.command()(pay)
426 app.command()(log)
@@ -388,3 +430,7 @@ def register(app: typer.Typer):
430 # Developer-only debugging aid; hidden from `colab --help` but still
431 # reachable via `colab whoami` / `colab whoami --help`.
432 app.command(name="whoami", hidden=True)(whoami)
433 + app.command(name="readme")(readme)
434 + app.command(name="README", hidden=True)(readme)
435 + app.command(name="agent")(agent)
436 + app.command(name="AGENT", hidden=True)(agent)
tests/test_readme.py new
+92
@@ -0,0 +1,92 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +from unittest.mock import patch, mock_open, MagicMock
16 +from typer.testing import CliRunner
17 +import pytest
18 +
19 +from colab_cli.cli import app
20 +
21 +runner = CliRunner()
22 +
23 +
24 +@pytest.fixture
25 +def mock_resources():
26 + with patch("importlib.resources.files") as mock:
27 + yield mock
28 +
29 +
30 +def test_readme_from_resources(mock_resources):
31 + mock_readme = MagicMock()
32 + mock_readme.is_file.return_value = True
33 + mock_readme.read_text.return_value = "Fake README content"
34 +
35 + def joinpath_side_effect(name):
36 + if name == "README.md":
37 + return mock_readme
38 + return MagicMock(is_file=MagicMock(return_value=False))
39 +
40 + mock_resources.return_value.joinpath.side_effect = joinpath_side_effect
41 +
42 + result = runner.invoke(app, ["README"])
43 + assert result.exit_code == 0
44 + assert result.output.strip() == "Fake README content"
45 + mock_resources.assert_called_once_with("colab_cli")
46 + mock_resources.return_value.joinpath.assert_called_with("README.md")
47 +
48 +
49 +def test_agent_from_resources(mock_resources):
50 + mock_agents = MagicMock()
51 + mock_agents.is_file.return_value = True
52 + mock_agents.read_text.return_value = "Fake AGENTS content"
53 +
54 + def joinpath_side_effect(name):
55 + if name == "AGENTS.md":
56 + return mock_agents
57 + return MagicMock(is_file=MagicMock(return_value=False))
58 +
59 + mock_resources.return_value.joinpath.side_effect = joinpath_side_effect
60 +
61 + result = runner.invoke(app, ["AGENT"])
62 + assert result.exit_code == 0
63 + assert result.output.strip() == "Fake AGENTS content"
64 + mock_resources.assert_called_once_with("colab_cli")
65 + mock_resources.return_value.joinpath.assert_called_with("AGENTS.md")
66 +
67 +
68 +def test_readme_fallback_to_file(mock_resources):
69 + mock_resources.side_effect = Exception("No resources")
70 +
71 + import builtins
72 +
73 + real_open = builtins.open
74 +
75 + def mock_open_impl(file, *args, **kwargs):
76 + if "README.md" in str(file):
77 + return mock_open(read_data="Fake local README")(*args, **kwargs)
78 + return real_open(file, *args, **kwargs)
79 +
80 + with patch("os.path.exists", return_value=True):
81 + with patch("builtins.open", side_effect=mock_open_impl):
82 + result = runner.invoke(app, ["README"])
83 + assert result.exit_code == 0
84 + assert result.output.strip() == "Fake local README"
85 +
86 +
87 +def test_readme_failure(mock_resources):
88 + mock_resources.side_effect = Exception("No resources")
89 + with patch("os.path.exists", return_value=False):
90 + result = runner.invoke(app, ["README"])
91 + assert result.exit_code == 1
92 + assert "README.md content not available" in result.output
tests/test_update.py
+18 -1
@@ -119,10 +119,12 @@ def test_pypi_no_upgrade(
119 assert expected_message in result.output
120
121
122 -def test_pypi_upgrade_uses_pip_hint(app_version, fake_settings, mock_pypi):
122 +def test_pypi_upgrade_uses_pip_hint(mocker, app_version, fake_settings, mock_pypi):
123 app_version("1.0.0")
124 mock_pypi({"info": {"version": "1.1.0"}})
125 fake_settings()
126 + mocker.patch("sys.executable", "/usr/bin/python")
127 + mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux")
128
129 result = runner.invoke(app, ["update"])
130 assert result.exit_code == 0
@@ -130,6 +132,21 @@ def test_pypi_upgrade_uses_pip_hint(app_version, fake_settings, mock_pypi):
132 assert "Run 'pip install --upgrade google-colab-cli' to update." in result.output
133
134
135 +def test_pypi_upgrade_uses_uv_hint(mocker, app_version, fake_settings, mock_pypi):
136 + app_version("1.0.0")
137 + mock_pypi({"info": {"version": "1.1.0"}})
138 + fake_settings()
139 + mocker.patch(
140 + "sys.executable", "/home/user/.local/share/uv/tools/google-colab-cli/bin/python"
141 + )
142 + mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux")
143 +
144 + result = runner.invoke(app, ["update"])
145 + assert result.exit_code == 0
146 + assert "available: 1.1.0 (current: 1.0.0)" in result.output
147 + assert "Run 'uv tool install -U google-colab-cli' to update." in result.output
148 +
149 +
150 def test_explicit_update_omits_disable_hint(app_version, fake_settings, mock_pypi):
151 """`colab update` is explicit user opt-in; the 'how to silence' line
152 should NOT appear (it would be condescending after the user just asked)."""