Skip to content

Add CLI for command-line interaction with Otter.ai#9

Open
andrewfurman wants to merge 10 commits intogmchad:mainfrom
andrewfurman:feature/cli
Open

Add CLI for command-line interaction with Otter.ai#9
andrewfurman wants to merge 10 commits intogmchad:mainfrom
andrewfurman:feature/cli

Conversation

@andrewfurman
Copy link

@andrewfurman andrewfurman commented Jan 12, 2026

Summary

  • Add a Click-based CLI for interacting with Otter.ai from the command line
  • Add credential storage in ~/.otterai/config.json with env var fallback
  • Full coverage of existing API methods via CLI commands
  • New: Add set_speech_title() method to rename speeches (not in original library)
  • New: Add set_transcript_speaker() method to tag speakers on transcript segments
  • New: Add folder management methods (create_folder, rename_folder, add_folder_speeches)

Important: otid vs speech_id

Otter.ai speeches have two identifiers in the API response:

  • speech_id (e.g. 22WB27HAEBEJYFCA) — internal ID, does NOT work with API endpoints
  • otid (e.g. jqb7OHo6mrHtCuMkyLN0nUS8mxY) — the ID used in all API calls

All CLI commands that accept a SPEECH_ID argument expect the otid value. The existing otterai.py API methods already use otid correctly in their payloads. This is now documented in the README and CLI docstring.

Recent Changes (Feb 2026)

  • --days N filter on speeches list — show only speeches from last N days
  • Human-readable output — timestamps (US Eastern), durations, speakers, folder names, [LIVE] indicator
  • Folder name resolution--folder "CoverNode" instead of numeric IDs in both list and move
  • --create flag on speeches move — auto-create destination folder if it doesn't exist

New Files

  • otterai/cli.py - CLI commands using Click
  • otterai/config.py - Credential management
  • tests/test_cli.py - 19 tests for CLI functionality
  • CLI_GUIDE.txt - Quick reference for CLI usage

Modified Files

  • otterai/otterai.py - Added new API methods (see below)
  • pyproject.toml - Added click dependency and otter entry point
  • README.md - Added CLI documentation

New API Methods

# Rename a speech
otter.set_speech_title(speech_id, "New Title")

# Tag a speaker on a transcript segment
otter.set_transcript_speaker(
    speech_id="abc123",
    transcript_uuid="uuid-here",
    speaker_id=12345,
    speaker_name="John Doe",
    create_speaker=False
)

# Create a new folder
otter.create_folder("Folder Name")

# Rename a folder
otter.rename_folder(folder_id, "New Name")

# Move speeches to a folder
otter.add_folder_speeches(folder_id, ["speech_id_1", "speech_id_2"])

CLI Commands

otter login/logout/user     - Authentication
otter speeches list/get/search/download/upload/trash/rename/move
otter speakers list/create/tag
otter folders list/create/rename
otter groups list
otter config show/clear

Example Workflows

# List recent conversations with rich output
otter speeches list --days 2

# Move speeches to a folder by name (auto-create if needed)
otter speeches move <otid1> <otid2> --folder "Meeting Notes" --create

# Rename and organize
otter speeches rename <otid> "CoverNode Daily Dev Sync on Mon Feb 16th 2026 @ 10:05am ET"
otter speeches move <otid> --folder "CoverNode"

Speaker Tagging

# List transcript segments in a speech
otter speakers tag <speech_id> <speaker_id>

# Tag a specific segment
otter speakers tag <speech_id> <speaker_id> -t <uuid>

# Tag all segments with a speaker
otter speakers tag <speech_id> <speaker_id> --all

Folder Management

# List all folders
otter folders list

# Create a new folder
otter folders create "Project Notes"

# Rename a folder
otter folders rename 12345 "New Folder Name"

# Move speeches using folder name instead of ID
otter speeches move <otid> --folder "Project Notes"

Test Checklist

  • otter login - authenticate with email/password
  • otter speeches list - lists conversations with readable timestamps
  • otter speeches list --days 2 - filters to last 2 days
  • otter speeches list --folder "CoverNode" - resolves folder name
  • otter speeches get <otid> - shows transcript with formatted output
  • otter speeches download <otid> - downloads file
  • otter speeches rename <otid> "Title" - renames speech
  • otter speeches move <otid> --folder "Name" --create - creates folder and moves
  • otter speakers list - lists speakers
  • otter speakers tag <speech_id> <speaker_id> - lists segments
  • otter speakers tag <speech_id> <speaker_id> --all - tags all segments
  • otter folders list - lists folders with speech counts
  • otter folders create "Name" - creates new folder
  • --json flag works on list commands

🤖 Generated with Claude Code

andrewfurman and others added 2 commits January 11, 2026 19:47
- 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>
@andrewfurman
Copy link
Author

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 list

All 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!

andrewfurman and others added 4 commits January 11, 2026 20:23
- 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.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 otter CLI with commands for auth, speeches, speakers, folders, groups, and config management.
  • Add ~/.otterai/config.json credential storage with environment variable fallback.
  • Extend OtterAI with 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.

Comment on lines +316 to +322
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
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +6
import tempfile
from pathlib import Path
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused imports (tempfile, Path) add noise and can fail linting if enabled. Remove them or use them in the tests.

Suggested change
import tempfile
from pathlib import Path

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +53
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

Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +241 to +273
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)
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
otterai/cli.py Outdated
Comment on lines +176 to +177
# Print transcript if available
transcripts = data.get("transcripts", [])
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
# 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", [])

Copilot uses AI. Check for mistakes.
Comment on lines +373 to +376
headers = {
"x-csrftoken": self._cookies["csrftoken"],
"referer": "https://otter.ai/",
}
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
try:
config = json.loads(CONFIG_FILE.read_text())
return config.get("username"), config.get("password")
except (json.JSONDecodeError, KeyError):
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
except (json.JSONDecodeError, KeyError):
except (json.JSONDecodeError, AttributeError, TypeError):

Copilot uses AI. Check for mistakes.
otterai/cli.py Outdated
if as_json:
click.echo(json.dumps(result["data"], indent=2))
else:
click.echo(json.dumps(result["data"], indent=2))
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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}")

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +104
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)

Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
otterai/cli.py Outdated
Comment on lines +627 to +630
if as_json:
click.echo(json.dumps(result["data"], indent=2))
else:
click.echo(json.dumps(result["data"], indent=2))
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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))

Copilot uses AI. Check for mistakes.
- 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>
@andrewfurman
Copy link
Author

All 18 review comments from Copilot have been addressed in commit d9721c5. Here's a summary:

Bug fixes:

  • create_folder() now includes params={"userid": self._userid} for API parity
  • All folder methods use .get("csrftoken", "") instead of ["csrftoken"] to avoid TypeError/KeyError
  • Speaker lookup in speakers tag now uses s.get("speaker_id") instead of s.get("id")
  • Transcript lookup in speeches get now supports both nested and top-level formats
  • Config exception handling broadened to catch AttributeError/TypeError for malformed JSON

CLI improvements:

  • get_authenticated_client() now raises click.ClickException instead of calling sys.exit(1) directly
  • speeches search and groups list --json flags now produce distinct output (human-readable vs JSON)

Test coverage added (9 new tests, 49 total):

  • Invalid-userid tests for set_speech_title, set_transcript_speaker, create_folder, rename_folder, add_folder_speeches
  • CLI tests for speakers tag (list segments mode), folders list, folders create, groups list

Other:

  • Removed unused tempfile/Path imports from test_cli.py
  • Added environment variable documentation (OTTERAI_USERNAME/OTTERAI_PASSWORD) to README

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>
andrewfurman and others added 2 commits February 17, 2026 10:04
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants