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)."""