Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ This makes the `tdc` command available globally.
tdc auth login
```

This opens your browser to authenticate with Comms. Once approved, the token is stored in your OS credential manager:
This opens Todoist OAuth in your browser. The default grant can read Comms data and create/update content and messages. It does not include delete, channel management, or user/workspace write scopes; use `--read-only` for read-only access or `--full-access` when needed.

Once approved, the token is stored in your OS credential manager:

- macOS: Keychain
- Windows: Credential Manager
Expand Down Expand Up @@ -96,11 +98,11 @@ Point the CLI at a non-production Comms instance with `COMMS_BASE_URL`:

```bash
export COMMS_BASE_URL=https://comms.staging.todoist.com
export COMMS_API_TOKEN=<staging-token>
tdc auth login
tdc user
```

The base URL is threaded through both the SDK and the search endpoint. You need a token issued by that environment — production tokens are rejected.
The base URL is used for OAuth, the SDK, and search. OAuth login supports `comms.todoist.com`, `comms.staging.todoist.com`, and `comms.local.todoist.com`; staging uses `https://staging.todoist.com` as the Todoist OAuth host. For custom Comms hosts, use `COMMS_API_TOKEN`.

### Auth commands

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"CHANGELOG.md"
],
"dependencies": {
"@doist/cli-core": "0.24.0",
"@doist/cli-core": "0.25.0",
"@doist/comms-sdk": "0.4.1",
"@pnpm/tabtab": "0.5.4",
"chalk": "5.6.2",
Expand Down
8 changes: 6 additions & 2 deletions skills/comms-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ Access Comms messaging via the `tdc` CLI. Use when the user asks about their Com
## Setup

```bash
tdc auth login # OAuth login (opens browser, read-write)
tdc auth login # OAuth login (standard write scopes)
tdc auth login --read-only # OAuth login with read-only scope
tdc auth login --full-access # OAuth login with delete/admin scopes
tdc auth login --callback-port <n># Override the local OAuth callback port (default 8766)
tdc auth login --json # Emit a JSON envelope for scripted / agent use
tdc auth login --ndjson # Emit an NDJSON envelope for scripted / agent use
Expand All @@ -42,7 +43,7 @@ tdc update # Update CLI to latest version
tdc changelog # Show recent changelog entries
```

Stored auth uses the system credential manager when available. If secure storage is unavailable, `tdc` warns and falls back to `~/.config/comms-cli/config.json`. `COMMS_API_TOKEN` always takes priority over the stored token.
OAuth login uses Todoist OAuth for Comms access. The default grant can read Comms data and create/update content or messages. It does not include delete, channel management, or user/workspace write scopes; use `tdc auth login --full-access` only when needed. Stored auth uses the system credential manager when available. If secure storage is unavailable, `tdc` warns and falls back to `~/.config/comms-cli/config.json`. `COMMS_API_TOKEN` always takes priority over the stored token.

In read-only mode (`tdc auth login --read-only`), commands that modify Comms data (reply, archive, react, delete, etc.) are blocked by the CLI. Externally provided tokens (`COMMS_API_TOKEN` or `tdc auth token`) are treated as unknown scope and assumed write-capable.

Expand All @@ -58,6 +59,8 @@ Routes automatically based on URL structure:
- Thread+comment URL → `tdc thread view` (comment ID extracted from URL)
- Thread URL → `tdc thread view`

URLs may use either `https://comms.todoist.com/{workspaceId}/...` or `https://comms.todoist.com/a/{workspaceId}/...`.

All target command flags pass through (e.g. `--json`, `--raw`, `--full`).

## Inbox
Expand Down Expand Up @@ -392,6 +395,7 @@ If no content argument is provided and no stdin is piped, the CLI opens `$EDITOR

**View by URL (auto-routes to the right command):**
```bash
tdc view https://comms.todoist.com/1585/ch/100/t/200 # View thread
tdc view https://comms.todoist.com/a/1585/ch/100/t/200 # View thread
tdc view https://comms.todoist.com/a/1585/ch/100/t/200/c/300 # View comment
tdc view https://comms.todoist.com/a/1585/msg/400 # View conversation
Expand Down
161 changes: 139 additions & 22 deletions src/commands/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ vi.mock('../../lib/auth.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../lib/auth.js')>()
return {
...actual,
getAuthMetadata: vi.fn(),
getApiTokenSnapshot: vi.fn(),
probeApiToken: vi.fn(),
}
})
Expand Down Expand Up @@ -43,6 +43,15 @@ vi.mock('../../lib/api.js', async (importOriginal) => {
}
})

vi.mock('../../lib/config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../lib/config.js')>()
return {
...actual,
getConfig: vi.fn(),
updateConfig: vi.fn(),
}
})

// Mock cli-core's auth subpath so login subcommand wiring doesn't drive a real
// OAuth flow during tests. The provider + token-store units are exercised in
// src/lib/auth-provider.test.ts. attachLogoutCommand + attachStatusCommand
Expand Down Expand Up @@ -79,16 +88,19 @@ import { attachLoginCommand } from '@doist/cli-core/auth'
import { CommsRequestError, type User } from '@doist/comms-sdk'
import { createWrappedCommsClient } from '../../lib/api.js'
import { type CommsAccount, type CommsTokenStore } from '../../lib/auth-provider.js'
import { getAuthMetadata, TOKEN_ENV_VAR } from '../../lib/auth.js'
import { getApiTokenSnapshot, TOKEN_ENV_VAR } from '../../lib/auth.js'
import { getConfig, updateConfig } from '../../lib/config.js'
import { resetGlobalArgs } from '../../lib/global-args.js'
import { registerAuthCommand } from './index.js'
import { attachCommsStatusCommand } from './status.js'

const mockCreateInterface = vi.mocked(createInterface)

const mockGetAuthMetadata = vi.mocked(getAuthMetadata)
const mockGetApiTokenSnapshot = vi.mocked(getApiTokenSnapshot)
const mockCreateWrappedCommsClient = vi.mocked(createWrappedCommsClient)
const mockAttachLoginCommand = vi.mocked(attachLoginCommand)
const mockGetConfig = vi.mocked(getConfig)
const mockUpdateConfig = vi.mocked(updateConfig)

const createProgram = () => createTestProgram(registerAuthCommand)

Expand Down Expand Up @@ -123,6 +135,27 @@ describe('auth command', () => {

const STORED_SNAPSHOT = { token: 'tk_stored_1234567890', account: STORED_ACCOUNT }
const STORED_RECORDS = [{ account: STORED_ACCOUNT, isDefault: true }]
const COMMS_SCOPE =
'user:read comms:content:read comms:content:write comms:messages:read comms:messages:write'

function workspace(id: number, name = `Workspace ${id}`) {
return {
id,
name,
creator: 1,
created: new Date('2026-01-01T00:00:00Z'),
plan: 'business',
}
}

function mockWorkspaceClient(workspaces: ReturnType<typeof workspace>[]) {
const getWorkspaces = vi.fn().mockResolvedValue(workspaces)
mockCreateWrappedCommsClient.mockReturnValue({
workspaces: { getWorkspaces },
// biome-ignore lint/suspicious/noExplicitAny: only the method used by these tests matters
} as any)
return getWorkspaces
}

describe('token subcommand', () => {
let originalIsTTY: boolean | undefined
Expand Down Expand Up @@ -359,22 +392,27 @@ describe('auth command', () => {
vi.stubEnv(TOKEN_ENV_VAR, '')
storeMocks.list.mockResolvedValue(STORED_RECORDS)
storeMocks.active.mockResolvedValue(STORED_SNAPSHOT)
mockGetApiTokenSnapshot.mockResolvedValue({
token: 'tk_refreshed_1234567890',
account: {
...STORED_ACCOUNT,
authResource: 'https://comms.staging.todoist.com',
},
})
mockCreateWrappedCommsClient.mockReturnValue({
users: { getSessionUser: vi.fn().mockResolvedValue(TEST_USER) },
// biome-ignore lint/suspicious/noExplicitAny: only the methods used in this test matter
} as any)
mockGetAuthMetadata.mockResolvedValue({
authMode: 'read-write',
authScope: 'user:read',
source: 'config',
})
process.argv = ['node', 'tdc', '--user', '1', 'auth', 'status']
resetGlobalArgs()

await createProgram().parseAsync(['node', 'tdc', 'auth', 'status'])

expect(storeMocks.active).toHaveBeenCalledWith('1')
expect(mockCreateWrappedCommsClient).toHaveBeenCalledWith('tk_stored_1234567890')
expect(mockGetApiTokenSnapshot).toHaveBeenCalledWith('1')
expect(mockCreateWrappedCommsClient).toHaveBeenCalledWith('tk_refreshed_1234567890', {
baseUrl: 'https://comms.staging.todoist.com',
})
expect(consoleSpy).toHaveBeenCalledWith('✓ Authenticated')
})

Expand Down Expand Up @@ -415,7 +453,8 @@ describe('auth command', () => {
id: String(TEST_USER.id),
label: TEST_USER.fullName,
authMode: 'read-write',
authScope: 'user:read threads:read',
authScope: COMMS_SCOPE,
authResource: 'https://comms.staging.todoist.com',
}

function programWithSnapshot(): Command {
Expand Down Expand Up @@ -449,21 +488,22 @@ describe('auth command', () => {
}

beforeEach(() => {
mockGetApiTokenSnapshot.mockResolvedValue({
token: 'snapshot_token',
account: SNAPSHOT_ACCOUNT,
})
mockCreateWrappedCommsClient.mockReturnValue({
users: { getSessionUser: vi.fn().mockResolvedValue(TEST_USER) },
// biome-ignore lint/suspicious/noExplicitAny: only the methods used in this test matter
} as any)
mockGetAuthMetadata.mockResolvedValue({
authMode: 'read-write',
authScope: 'user:read threads:read',
source: 'config',
})
})

it('renders text status from the snapshot', async () => {
await programWithSnapshot().parseAsync(['node', 'tdc', 'auth', 'status'])

expect(mockCreateWrappedCommsClient).toHaveBeenCalledWith('snapshot_token')
expect(mockCreateWrappedCommsClient).toHaveBeenCalledWith('snapshot_token', {
baseUrl: 'https://comms.staging.todoist.com',
})
expect(consoleSpy).toHaveBeenCalledWith('✓ Authenticated')
expect(consoleSpy).toHaveBeenCalledWith(' Email: test@example.com')
expect(consoleSpy).toHaveBeenCalledWith(' Name: Test User')
Expand All @@ -479,7 +519,7 @@ describe('auth command', () => {
email: 'test@example.com',
name: 'Test User',
authMode: 'read-write',
authScope: 'user:read threads:read',
authScope: COMMS_SCOPE,
source: 'config',
})
})
Expand All @@ -498,7 +538,7 @@ describe('auth command', () => {
email: 'test@example.com',
name: 'Test User',
authMode: 'read-write',
authScope: 'user:read threads:read',
authScope: COMMS_SCOPE,
source: 'config',
})
})
Expand Down Expand Up @@ -560,6 +600,19 @@ describe('auth command', () => {
})

describe('login subcommand wiring', () => {
const OAUTH_ACCOUNT: CommsAccount = {
...STORED_ACCOUNT,
authResource: 'https://comms.todoist.com',
}

beforeEach(() => {
storeMocks.active.mockReset().mockResolvedValue(null)
storeMocks.getLastStorageResult.mockReset().mockReturnValue(undefined)
mockCreateWrappedCommsClient.mockReset()
mockGetConfig.mockReset().mockResolvedValue({})
mockUpdateConfig.mockReset().mockResolvedValue(undefined)
})

it('passes the comms provider, store, port, and renderers to cli-core attachLoginCommand', async () => {
createProgram()

Expand All @@ -579,15 +632,79 @@ describe('auth command', () => {
expect(options.renderError('boom')).toContain('Authentication failed')
})

it('resolveScopes returns the read-write scope list by default and read-only when --read-only is set', () => {
it('resolveScopes returns standard, read-only, or full-access OAuth scopes', () => {
createProgram()

const [, options] = mockAttachLoginCommand.mock.calls[0]
const writeScopes = options.resolveScopes({ readOnly: false, flags: {} })
const fullScopes = options.resolveScopes({
readOnly: false,
flags: { fullAccess: true },
})
const readScopes = options.resolveScopes({ readOnly: true, flags: {} })
expect(writeScopes).toContain('threads:write')
expect(readScopes).not.toContain('threads:write')
expect(readScopes).toContain('threads:read')
expect(writeScopes).toContain('comms:content:write')
expect(writeScopes).toContain('comms:messages:write')
expect(writeScopes).not.toContain('comms:content:delete')
expect(writeScopes).not.toContain('user:write')
expect(fullScopes).toContain('comms:content:delete')
expect(fullScopes).toContain('user:write')
expect(readScopes).not.toContain('comms:content:write')
expect(readScopes).toContain('comms:content:read')
})

it('registers --full-access and rejects combining it with --read-only', () => {
const program = createProgram()
const authCommand = program.commands.find((command) => command.name() === 'auth')
const loginCommand = authCommand?.commands.find((command) => command.name() === 'login')

expect(loginCommand?.options.some((option) => option.long === '--full-access')).toBe(
true,
)

const [, options] = mockAttachLoginCommand.mock.calls[0]
expect(() =>
options.resolveScopes({ readOnly: true, flags: { fullAccess: true } }),
).toThrow('Choose either --read-only or --full-access, not both.')
})

it('sets the only available workspace as current after successful OAuth login', async () => {
mockGetConfig.mockResolvedValue({ currentWorkspace: 48121 })
storeMocks.active.mockResolvedValue({
token: 'oauth-token',
account: OAUTH_ACCOUNT,
})
mockWorkspaceClient([workspace(69, 'Doist')])
createProgram()

const [, options] = mockAttachLoginCommand.mock.calls[0]
await options.onSuccess({
account: OAUTH_ACCOUNT,
view: { json: true, ndjson: false },
flags: {},
})

expect(mockUpdateConfig).toHaveBeenCalledTimes(1)
expect(mockUpdateConfig).toHaveBeenCalledWith({ currentWorkspace: 69 })
})

it('clears the current workspace when it no longer belongs to the logged-in account', async () => {
mockGetConfig.mockResolvedValue({ currentWorkspace: 48121 })
storeMocks.active.mockResolvedValue({
token: 'oauth-token',
account: OAUTH_ACCOUNT,
})
mockWorkspaceClient([workspace(69, 'Doist'), workspace(70)])
createProgram()

const [, options] = mockAttachLoginCommand.mock.calls[0]
await options.onSuccess({
account: OAUTH_ACCOUNT,
view: { json: true, ndjson: false },
flags: {},
})

expect(mockUpdateConfig).toHaveBeenCalledTimes(1)
expect(mockUpdateConfig).toHaveBeenCalledWith({ currentWorkspace: undefined })
})
})

Expand Down
Loading
Loading