Skip to content

Commit d175aa0

Browse files
chkp-ronizclaude
andcommitted
feat(cli): add --browser flag to login for Microsoft Edge SSO support
Some organizations enforce SSO policies that block authentication through standalone Chromium but allow Microsoft Edge. This adds a --browser option to the login command so users can authenticate via their system Edge browser. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8f9dd3a commit d175aa0

File tree

3 files changed

+130
-14
lines changed

3 files changed

+130
-14
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ pip install git+https://github.com/teng-lin/notebooklm-py@main
118118
```bash
119119
# 1. Authenticate (opens browser)
120120
notebooklm login
121+
# Or use Microsoft Edge (for orgs that require Edge for SSO)
122+
# notebooklm login --browser msedge
121123

122124
# 2. Create a notebook and add sources
123125
notebooklm create "My Research"

src/notebooklm/cli/session.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,20 @@ def register_session_commands(cli):
160160
default=None,
161161
help="Where to save storage_state.json (default: $NOTEBOOKLM_HOME/storage_state.json)",
162162
)
163-
def login(storage):
163+
@click.option(
164+
"--browser",
165+
type=click.Choice(["chromium", "msedge"], case_sensitive=False),
166+
default="chromium",
167+
help="Browser to use for login (default: chromium). Use 'msedge' for Microsoft Edge.",
168+
)
169+
def login(storage, browser):
164170
"""Log in to NotebookLM via browser.
165171
166172
Opens a browser window for Google login. After logging in,
167173
press ENTER in the terminal to save authentication.
168174
175+
Use --browser msedge if your organization requires Microsoft Edge for SSO.
176+
169177
Note: Cannot be used when NOTEBOOKLM_AUTH_JSON is set (use file-based
170178
auth or unset the env var first).
171179
"""
@@ -184,36 +192,43 @@ def login(storage):
184192
try:
185193
from playwright.sync_api import sync_playwright
186194
except ImportError:
187-
console.print(
188-
"[red]Playwright not installed. Run:[/red]\n"
189-
" pip install notebooklm[browser]\n"
190-
" playwright install chromium"
195+
install_hint = (
196+
" pip install notebooklm[browser]"
197+
if browser == "msedge"
198+
else " pip install notebooklm[browser]\n playwright install chromium"
191199
)
200+
console.print(f"[red]Playwright not installed. Run:[/red]\n{install_hint}")
192201
raise SystemExit(1) from None
193202

194-
# Pre-flight check: verify Chromium browser is installed
195-
_ensure_chromium_installed()
203+
# Pre-flight check: verify Chromium browser is installed (skip for Edge)
204+
if browser == "chromium":
205+
_ensure_chromium_installed()
196206

197207
storage_path = Path(storage) if storage else get_storage_path()
198208
browser_profile = get_browser_profile_dir()
199209
storage_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
200210
browser_profile.mkdir(parents=True, exist_ok=True, mode=0o700)
201211

202-
console.print("[yellow]Opening browser for Google login...[/yellow]")
212+
browser_label = "Microsoft Edge" if browser == "msedge" else "Chromium"
213+
console.print(f"[yellow]Opening {browser_label} for Google login...[/yellow]")
203214
console.print(f"[dim]Using persistent profile: {browser_profile}[/dim]")
204215

205216
# Use context manager to restore ProactorEventLoop for Playwright on Windows
206217
# (fixes #89: NotImplementedError on Windows Python 3.12)
207218
with _windows_playwright_event_loop(), sync_playwright() as p:
208-
context = p.chromium.launch_persistent_context(
209-
user_data_dir=str(browser_profile),
210-
headless=False,
211-
args=[
219+
launch_kwargs: dict[str, Any] = {
220+
"user_data_dir": str(browser_profile),
221+
"headless": False,
222+
"args": [
212223
"--disable-blink-features=AutomationControlled",
213224
"--password-store=basic", # Avoid macOS keychain encryption for headless compatibility
214225
],
215-
ignore_default_args=["--enable-automation"],
216-
)
226+
"ignore_default_args": ["--enable-automation"],
227+
}
228+
if browser == "msedge":
229+
launch_kwargs["channel"] = "msedge"
230+
231+
context = p.chromium.launch_persistent_context(**launch_kwargs)
217232

218233
page = context.pages[0] if context.pages else context.new_page()
219234
page.goto(NOTEBOOKLM_URL)

tests/unit/cli/test_session.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,105 @@ def test_login_blocked_when_notebooklm_auth_json_set(self, runner, monkeypatch):
8383
assert result.exit_code == 1
8484
assert "Cannot run 'login' when NOTEBOOKLM_AUTH_JSON is set" in result.output
8585

86+
def test_login_help_shows_browser_option(self, runner):
87+
"""Test login --help shows --browser option with chromium/msedge choices."""
88+
result = runner.invoke(cli, ["login", "--help"])
89+
90+
assert result.exit_code == 0
91+
assert "--browser" in result.output
92+
assert "chromium" in result.output
93+
assert "msedge" in result.output
94+
95+
def test_login_rejects_invalid_browser(self, runner):
96+
"""Test login rejects invalid --browser values."""
97+
result = runner.invoke(cli, ["login", "--browser", "firefox"])
98+
99+
assert result.exit_code != 0
100+
101+
def test_login_msedge_skips_chromium_install(self, runner, tmp_path):
102+
"""Test --browser msedge skips _ensure_chromium_installed."""
103+
with (
104+
patch("notebooklm.cli.session._ensure_chromium_installed") as mock_ensure,
105+
patch("playwright.sync_api.sync_playwright") as mock_pw,
106+
patch(
107+
"notebooklm.cli.session.get_storage_path", return_value=tmp_path / "storage.json"
108+
),
109+
patch(
110+
"notebooklm.cli.session.get_browser_profile_dir",
111+
return_value=tmp_path / "profile",
112+
),
113+
patch("notebooklm.cli.session._sync_server_language_to_config"),
114+
patch("builtins.input", return_value=""),
115+
):
116+
mock_context = MagicMock()
117+
mock_page = MagicMock()
118+
mock_page.url = "https://notebooklm.google.com/"
119+
mock_context.pages = [mock_page]
120+
mock_pw.return_value.__enter__.return_value.chromium.launch_persistent_context.return_value = mock_context
121+
122+
runner.invoke(cli, ["login", "--browser", "msedge"])
123+
124+
mock_ensure.assert_not_called()
125+
126+
def test_login_msedge_passes_channel_param(self, runner, tmp_path):
127+
"""Test --browser msedge passes channel='msedge' to launch_persistent_context."""
128+
with (
129+
patch("notebooklm.cli.session._ensure_chromium_installed"),
130+
patch("playwright.sync_api.sync_playwright") as mock_pw,
131+
patch(
132+
"notebooklm.cli.session.get_storage_path", return_value=tmp_path / "storage.json"
133+
),
134+
patch(
135+
"notebooklm.cli.session.get_browser_profile_dir",
136+
return_value=tmp_path / "profile",
137+
),
138+
patch("notebooklm.cli.session._sync_server_language_to_config"),
139+
patch("builtins.input", return_value=""),
140+
):
141+
mock_context = MagicMock()
142+
mock_page = MagicMock()
143+
mock_page.url = "https://notebooklm.google.com/"
144+
mock_context.pages = [mock_page]
145+
mock_launch = (
146+
mock_pw.return_value.__enter__.return_value.chromium.launch_persistent_context
147+
)
148+
mock_launch.return_value = mock_context
149+
150+
runner.invoke(cli, ["login", "--browser", "msedge"])
151+
152+
call_kwargs = mock_launch.call_args[1]
153+
assert call_kwargs.get("channel") == "msedge"
154+
155+
def test_login_chromium_default_no_channel(self, runner, tmp_path):
156+
"""Test default chromium does not pass channel and calls _ensure_chromium_installed."""
157+
with (
158+
patch("notebooklm.cli.session._ensure_chromium_installed") as mock_ensure,
159+
patch("playwright.sync_api.sync_playwright") as mock_pw,
160+
patch(
161+
"notebooklm.cli.session.get_storage_path", return_value=tmp_path / "storage.json"
162+
),
163+
patch(
164+
"notebooklm.cli.session.get_browser_profile_dir",
165+
return_value=tmp_path / "profile",
166+
),
167+
patch("notebooklm.cli.session._sync_server_language_to_config"),
168+
patch("builtins.input", return_value=""),
169+
):
170+
mock_context = MagicMock()
171+
mock_page = MagicMock()
172+
mock_page.url = "https://notebooklm.google.com/"
173+
mock_context.pages = [mock_page]
174+
mock_launch = (
175+
mock_pw.return_value.__enter__.return_value.chromium.launch_persistent_context
176+
)
177+
mock_launch.return_value = mock_context
178+
179+
runner.invoke(cli, ["login", "--browser", "chromium"])
180+
181+
mock_ensure.assert_called_once()
182+
call_kwargs = mock_launch.call_args[1]
183+
assert "channel" not in call_kwargs
184+
86185

87186
# =============================================================================
88187
# USE COMMAND TESTS

0 commit comments

Comments
 (0)