Add CLI for command-line interaction with Otter.ai#9
Add CLI for command-line interaction with Otter.ai#9andrewfurman wants to merge 10 commits intogmchad:mainfrom
Conversation
- Add otterai/config.py for credential storage (~/.otterai/config.json) - Add otterai/cli.py with full command coverage: - login/logout/user for authentication - speeches list/get/search/download/upload/trash - speakers list/create - folders list - groups list - config show/clear Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add tests/test_cli.py with 19 tests for CLI commands - Update pyproject.toml with click dependency and otter entry point - Add CLI documentation to README.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
0a6a4e3 to
75c7d61
Compare
|
Hey @gmchad! 👋 I've added a CLI tool to make it easier to interact with Otter.ai from the command line. This wraps all the existing API methods you built. Quick examples: otter login
otter speeches list --page-size 10
otter speeches download <id> --format txt
otter speakers listAll existing tests pass, and I've added 19 new tests for the CLI. Would you be able to review and merge this in? Thanks for building this library! |
- Add set_speech_title() to OtterAI class for renaming speeches - Add 'otter speeches rename <id> "title"' CLI command - Update README and CLI_GUIDE with rename documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add set_transcript_speaker() method to OtterAI class for tagging speakers on specific transcript segments via the /forward/api/v1/set_transcript_speaker endpoint - Add 'speakers tag' CLI command with options to: - List available transcript segments (default behavior) - Tag a specific segment with -t/--transcript-uuid - Tag all segments with -a/--all - Update CLI_GUIDE.txt with new command documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
New API methods: - create_folder(folder_name): Create new folders - rename_folder(folder_id, new_name): Rename existing folders - add_folder_speeches(folder_id, speech_ids): Move speeches to folders New CLI commands: - otter folders create "Name": Create a new folder - otter folders rename <id> "New Name": Rename a folder - otter speeches move <id> --folder <folder_id>: Move speech(es) to folder Also improved folders list output formatting. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Otter's API returns 403 without the Referer header for these endpoints. Also use .get() for csrftoken to avoid KeyError if missing.
There was a problem hiding this comment.
Pull request overview
This PR introduces a Click-based otter CLI for interacting with the existing OtterAI Python API, adds local credential management for CLI usage, and extends the API wrapper with new endpoints (speech rename, transcript speaker tagging, and folder management).
Changes:
- Add a new
otterCLI with commands for auth, speeches, speakers, folders, groups, and config management. - Add
~/.otterai/config.jsoncredential storage with environment variable fallback. - Extend
OtterAIwith new methods:set_speech_title,set_transcript_speaker, and folder management helpers.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
otterai/cli.py |
New Click CLI entry point and command implementations that call OtterAI. |
otterai/config.py |
New config/credential persistence and env var loading logic for the CLI. |
otterai/otterai.py |
Adds new API wrapper methods for renaming speeches, tagging transcript speakers, and folder operations. |
tests/test_cli.py |
New pytest suite validating core CLI wiring and config behavior via mocking. |
pyproject.toml |
Adds click dependency and registers the otter console script. |
README.md |
Adds CLI documentation section. |
CLI_GUIDE.txt |
Adds a quick-reference CLI usage guide. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
otterai/otterai.py
Outdated
| data = {"folder_name": folder_name} | ||
| headers = { | ||
| "x-csrftoken": self._cookies["csrftoken"], | ||
| "referer": "https://otter.ai/", | ||
| } | ||
| response = self._session.post( | ||
| create_folder_url, headers=headers, data=data |
There was a problem hiding this comment.
create_folder() doesn’t include the userid as query params (unlike create_speaker, rename_folder, etc.). Given the consistent pattern across this client, this is likely required by the API and will cause requests to fail. Add the params={"userid": self._userid} payload (or whatever the endpoint requires) for parity with other methods.
| data = {"folder_name": folder_name} | |
| headers = { | |
| "x-csrftoken": self._cookies["csrftoken"], | |
| "referer": "https://otter.ai/", | |
| } | |
| response = self._session.post( | |
| create_folder_url, headers=headers, data=data | |
| payload = {"userid": self._userid} | |
| data = {"folder_name": folder_name} | |
| headers = { | |
| "x-csrftoken": self._cookies["csrftoken"], | |
| "referer": "https://otter.ai/", | |
| } | |
| response = self._session.post( | |
| create_folder_url, params=payload, headers=headers, data=data |
tests/test_cli.py
Outdated
| import tempfile | ||
| from pathlib import Path |
There was a problem hiding this comment.
Unused imports (tempfile, Path) add noise and can fail linting if enabled. Remove them or use them in the tests.
| import tempfile | |
| from pathlib import Path |
| def test_cli_version(runner): | ||
| """Test that --version works.""" | ||
| result = runner.invoke(main, ["--version"]) | ||
| assert result.exit_code == 0 | ||
| assert "0.1.0" in result.output | ||
|
|
There was a problem hiding this comment.
The CLI version is hard-coded in both the implementation and the tests. This tends to drift from the package version in pyproject.toml and creates extra work when bumping releases. Consider deriving it from package metadata (e.g., importlib.metadata.version) and updating the test to assert that behavior instead of a literal string.
| def set_transcript_speaker(self, speech_id, transcript_uuid, speaker_id, speaker_name, create_speaker=False): | ||
| """Tag a speaker on a specific transcript segment. | ||
|
|
||
| Args: | ||
| speech_id: The speech/conversation otid | ||
| transcript_uuid: UUID of the specific transcript segment | ||
| speaker_id: ID of existing speaker (from get_speakers) | ||
| speaker_name: Name of the speaker | ||
| create_speaker: If True, create new speaker if not exists | ||
|
|
||
| Returns: | ||
| Response dict with status and data | ||
| """ | ||
| set_speaker_url = OtterAI.API_BASE_URL + "set_transcript_speaker" | ||
| if self._is_userid_invalid(): | ||
| raise OtterAIException("userid is invalid") | ||
|
|
||
| payload = { | ||
| "speech_otid": speech_id, | ||
| "transcript_uuid": transcript_uuid, | ||
| "speaker_name": speaker_name, | ||
| "userid": self._userid, | ||
| "create_speaker": str(create_speaker).lower(), | ||
| "speaker_id": speaker_id, | ||
| } | ||
|
|
||
| headers = { | ||
| "referer": "https://otter.ai/", | ||
| "x-csrftoken": self._cookies.get("csrftoken", ""), | ||
| } | ||
| response = self._session.get(set_speaker_url, params=payload, headers=headers) | ||
|
|
||
| return self._handle_response(response) |
There was a problem hiding this comment.
New API method set_transcript_speaker() isn’t exercised by tests. Consider adding at least an invalid-userid test (consistent with other methods) and a mocked-request unit test to verify the expected URL, params, and headers are used.
otterai/cli.py
Outdated
| # Print transcript if available | ||
| transcripts = data.get("transcripts", []) |
There was a problem hiding this comment.
The CLI is inconsistent about where transcripts are found in the get_speech response: speeches get reads data.get("transcripts"), but speakers tag reads data.get("speech", {}).get("transcripts"). These can’t both be correct; align both code paths to the actual API response shape (and consider handling both shapes for backwards compatibility).
| # Print transcript if available | |
| transcripts = data.get("transcripts", []) | |
| # Print transcript if available (support both nested and top-level formats) | |
| transcripts = speech.get("transcripts") or data.get("transcripts", []) |
| headers = { | ||
| "x-csrftoken": self._cookies["csrftoken"], | ||
| "referer": "https://otter.ai/", | ||
| } |
There was a problem hiding this comment.
Same CSRF cookie indexing issue here: self._cookies["csrftoken"] can raise if cookies aren’t set. Use .get() consistently across the client so these methods fail gracefully with a meaningful error.
otterai/config.py
Outdated
| try: | ||
| config = json.loads(CONFIG_FILE.read_text()) | ||
| return config.get("username"), config.get("password") | ||
| except (json.JSONDecodeError, KeyError): |
There was a problem hiding this comment.
load_credentials() catches KeyError, but config.get(...) won’t raise KeyError; the more likely failure mode for malformed-but-valid JSON is AttributeError (e.g., if the JSON is a list) or TypeError. Consider broadening the exception handling to avoid the CLI crashing on a corrupted config file, and optionally warn the user that the config is invalid.
| except (json.JSONDecodeError, KeyError): | |
| except (json.JSONDecodeError, AttributeError, TypeError): |
otterai/cli.py
Outdated
| if as_json: | ||
| click.echo(json.dumps(result["data"], indent=2)) | ||
| else: | ||
| click.echo(json.dumps(result["data"], indent=2)) |
There was a problem hiding this comment.
Folders/groups CLI commands are not covered by the new tests/test_cli.py. Since these commands are part of the advertised CLI surface, add a couple of CliRunner tests (mocking OtterAI) to validate success/error handling and --json output.
| click.echo(json.dumps(result["data"], indent=2)) | |
| groups = result["data"] | |
| if not groups: | |
| click.echo("No groups found.") | |
| else: | |
| for group in groups: | |
| if isinstance(group, dict): | |
| name = group.get("name", "<no name>") | |
| group_id = group.get("id") | |
| if group_id is not None: | |
| click.echo(f"- {name} (id: {group_id})") | |
| else: | |
| click.echo(f"- {name}") | |
| else: | |
| click.echo(f"- {group}") |
| def set_speech_title(self, speech_id, title): | ||
| set_title_url = OtterAI.API_BASE_URL + "set_speech_title" | ||
| if self._is_userid_invalid(): | ||
| raise OtterAIException("userid is invalid") | ||
|
|
||
| payload = {"otid": speech_id, "title": title} | ||
|
|
||
| response = self._session.get(set_title_url, params=payload) | ||
|
|
||
| return self._handle_response(response) | ||
|
|
There was a problem hiding this comment.
New public API method set_speech_title() isn’t covered by tests. Existing API methods in this module generally have at least an "invalid userid" test in tests/test_otterai.py; adding similar coverage for this new method would help prevent regressions.
otterai/cli.py
Outdated
| if as_json: | ||
| click.echo(json.dumps(result["data"], indent=2)) | ||
| else: | ||
| click.echo(json.dumps(result["data"], indent=2)) |
There was a problem hiding this comment.
Same issue as above: both the --json and non---json branches output identical JSON, making the flag redundant. Consider emitting a summarized human-readable format when --json is not provided, or drop the option.
| if as_json: | |
| click.echo(json.dumps(result["data"], indent=2)) | |
| else: | |
| click.echo(json.dumps(result["data"], indent=2)) | |
| data = result.get("data") | |
| if as_json: | |
| click.echo(json.dumps(data, indent=2)) | |
| else: | |
| # Human-readable summary | |
| if isinstance(data, list): | |
| click.echo(f"Found {len(data)} groups:") | |
| for idx, group in enumerate(data, start=1): | |
| if isinstance(group, dict): | |
| name = group.get("name") or group.get("group_name") or group.get("id") or "Unknown" | |
| group_id = group.get("id") | |
| if group_id is not None: | |
| click.echo(f"{idx}. {name} (id: {group_id})") | |
| else: | |
| click.echo(f"{idx}. {name}") | |
| else: | |
| click.echo(f"{idx}. {group}") | |
| else: | |
| # Fallback for unexpected structures | |
| click.echo(str(data)) |
- Fix create_folder() missing userid param and CSRF cookie access - Use .get() for CSRF token across all folder methods - Use click.ClickException instead of sys.exit in get_authenticated_client - Fix transcript lookup to support both nested and top-level formats - Fix speaker ID lookup key (speaker_id vs id) - Make --json flag meaningful for search and groups commands - Broaden config exception handling for malformed JSON - Remove unused imports in tests - Add env var docs to README - Add test coverage for new API methods and CLI commands Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
All 18 review comments from Copilot have been addressed in commit d9721c5. Here's a summary: Bug fixes:
CLI improvements:
Test coverage added (9 new tests, 49 total):
Other:
All 49 tests passing. ✅ |
macOS system Python uses LibreSSL instead of OpenSSL, which causes urllib3 v2 to emit a noisy warning on every CLI invocation. Filter the warning before any imports to keep CLI output clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
54bd344 to
53da7f0
Compare
…d folder name support - Document that CLI commands expect otid (not speech_id) in README and CLI docstring - Add --days flag to `speeches list` to filter by recent N days - Show human-readable timestamps, durations, folder, speakers, and [LIVE] indicator in list output - Accept folder names (not just IDs) in `speeches list --folder` and `speeches move --folder` - Add --create flag to `speeches move` to auto-create folders - Show richer output in `speeches get` (formatted timestamps, folder, speakers) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
~/.otterai/config.jsonwith env var fallbackset_speech_title()method to rename speeches (not in original library)set_transcript_speaker()method to tag speakers on transcript segmentscreate_folder,rename_folder,add_folder_speeches)Important:
otidvsspeech_idOtter.ai speeches have two identifiers in the API response:
speech_id(e.g.22WB27HAEBEJYFCA) — internal ID, does NOT work with API endpointsotid(e.g.jqb7OHo6mrHtCuMkyLN0nUS8mxY) — the ID used in all API callsAll CLI commands that accept a
SPEECH_IDargument expect the otid value. The existingotterai.pyAPI methods already useotidcorrectly in their payloads. This is now documented in the README and CLI docstring.Recent Changes (Feb 2026)
--days Nfilter onspeeches list— show only speeches from last N days[LIVE]indicator--folder "CoverNode"instead of numeric IDs in bothlistandmove--createflag onspeeches move— auto-create destination folder if it doesn't existNew Files
otterai/cli.py- CLI commands using Clickotterai/config.py- Credential managementtests/test_cli.py- 19 tests for CLI functionalityCLI_GUIDE.txt- Quick reference for CLI usageModified Files
otterai/otterai.py- Added new API methods (see below)pyproject.toml- Addedclickdependency andotterentry pointREADME.md- Added CLI documentationNew API Methods
CLI Commands
Example Workflows
Speaker Tagging
Folder Management
Test Checklist
otter login- authenticate with email/passwordotter speeches list- lists conversations with readable timestampsotter speeches list --days 2- filters to last 2 daysotter speeches list --folder "CoverNode"- resolves folder nameotter speeches get <otid>- shows transcript with formatted outputotter speeches download <otid>- downloads fileotter speeches rename <otid> "Title"- renames speechotter speeches move <otid> --folder "Name" --create- creates folder and movesotter speakers list- lists speakersotter speakers tag <speech_id> <speaker_id>- lists segmentsotter speakers tag <speech_id> <speaker_id> --all- tags all segmentsotter folders list- lists folders with speech countsotter folders create "Name"- creates new folder--jsonflag works on list commands🤖 Generated with Claude Code