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
19 changes: 13 additions & 6 deletions .agents/skills/add-command/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ Color convention:
- `green` — create/join operations
- `yellow` — update/delete/archive mutations

## 3. Command Implementation (`src/commands/<entity>/`)
## 3. Read-Only Permissions (`src/lib/permissions.ts`)

If the new command uses a **read-only** SDK method (e.g., `getXxx`, `listXxx`), add it to the `KNOWN_SAFE_API_METHODS` set. This set uses a default-deny approach: any method **not** listed is treated as mutating and will be blocked when the CLI is authenticated with a read-only OAuth token (`td auth login --read-only`).

- **Read-only methods** (fetch/list/view): add to `KNOWN_SAFE_API_METHODS`
- **Mutating methods** (add/update/delete/archive/move): do NOT add — they are blocked by default, which is the correct behavior

## 4. Command Implementation (`src/commands/<entity>/`)

Commands with multiple subcommands use a folder-based structure:

Expand Down Expand Up @@ -83,7 +90,7 @@ const myCmd = parent

The variable assignment (`const myCmd = ...`) is needed so the `.action()` callback can call `myCmd.help()` when the argument is missing.

## 4. Accessibility (`src/lib/output.ts`)
## 5. Accessibility (`src/lib/output.ts`)

The CLI supports accessible mode via `isAccessible()` (checks `TD_ACCESSIBLE=1` or `--accessible` flag). When adding output that uses color or visual elements, consider whether information is conveyed **only** by color or decoration.

Expand Down Expand Up @@ -119,7 +126,7 @@ if (isAccessible()) {

If adding a new shared formatter to `output.ts`, use `Record<ExactType, ...>` rather than `Record<string, ...>` so the compiler catches missing variants.

## 5. Tests (`src/__tests__/<entity>.test.ts`)
## 6. Tests (`src/__tests__/<entity>.test.ts`)

Follow the existing pattern: mock `getApi`, use `program.parseAsync()`.

Expand All @@ -130,7 +137,7 @@ Always test:
- `--dry-run` for mutating commands (API method should NOT be called, preview text shown)
- `--json` output where applicable

## 6. Skill Content (`src/lib/skills/content.ts`)
## 7. Skill Content (`src/lib/skills/content.ts`)

Update `SKILL_CONTENT` with examples for the new command. Update relevant sections:

Expand All @@ -139,7 +146,7 @@ Update `SKILL_CONTENT` with examples for the new command. Update relevant sectio
- Mutating `--json` list if the command returns an entity
- `--dry-run` list if applicable

## 7. Sync Skill File
## 8. Sync Skill File

After all code changes are complete:

Expand All @@ -149,7 +156,7 @@ npm run sync:skill

This builds the project and regenerates `skills/todoist-cli/SKILL.md` from the compiled skill content. The regenerated file must be committed. CI will fail (`npm run check:skill-sync`) if it is out of sync.

## 8. Verify
## 9. Verify

```bash
npm run type-check
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ This opens your browser to authenticate with Todoist. Once approved, the token i

If secure storage is unavailable, the CLI warns and falls back to `~/.config/todoist-cli/config.json`. Existing plaintext tokens are migrated automatically the next time the CLI reads them successfully from the config file.

For a read-only OAuth token (scope `data:read`), run:

```bash
td auth login --read-only
```

In read-only mode, commands that change Todoist data (create/update/delete/complete/move/archive, etc.) are blocked by the CLI.

### Alternative methods

**Manual token:** Get your API token from [Todoist Settings > Integrations > Developer](https://todoist.com/app/settings/integrations/developer):
Expand All @@ -88,11 +96,19 @@ export TODOIST_API_TOKEN="your-token"

`TODOIST_API_TOKEN` always takes priority over the stored token.

Note: externally provided tokens (`TODOIST_API_TOKEN` or `td auth token`) are treated as unknown scope and assumed write-capable. The CLI cannot currently auto-detect OAuth scope for these tokens.

### Auth commands

```bash
td auth status # check if authenticated
td auth logout # remove saved token
td auth status # check if authenticated + mode (read-only/read-write/unknown)
td auth logout # remove saved token and auth metadata
```

To switch back to normal write access, re-run:

```bash
td auth login
```

## Usage
Expand Down
12 changes: 7 additions & 5 deletions skills/todoist-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Use this skill when the user wants to interact with their Todoist tasks.
- `td reminder list` - List reminders (all or per task)
- `td reminder add` - Task reminders
- `td template create/export-file/export-url/import-file/import-id` - Project templates
- `td auth login --read-only` - Authenticate with read-only OAuth scope
- `td auth status` - Authentication status
- `td stats` - Productivity stats
- `td settings view` - User settings
Expand Down Expand Up @@ -394,11 +395,12 @@ td template import-id "My Project" --template-id product-launch --locale fr # W

### Auth
```bash
td auth status # Check authentication
td auth status --json # JSON: { id, email, fullName }
td auth login # OAuth login
td auth token <token> # Save API token
td auth logout # Remove saved token
td auth login # OAuth login (read-write)
td auth login --read-only # OAuth login with scope data:read
td auth token "your-token" # Save manual token (scope unknown; assumed write-capable)
td auth status # Show auth state + mode
td auth status --json # JSON: { id, email, fullName, authMode, authScope }
td auth logout # Remove token + auth metadata
```

### Stats
Expand Down
95 changes: 83 additions & 12 deletions src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ vi.mock('../lib/auth.js', async (importOriginal) => {
...actual,
saveApiToken: vi.fn(),
clearApiToken: vi.fn(),
getAuthMetadata: vi.fn(),
}
})

Expand All @@ -33,10 +34,14 @@ vi.mock('../lib/oauth-server.js', () => ({
}))

// Mock OAuth module
vi.mock('../lib/oauth.js', () => ({
buildAuthorizationUrl: vi.fn(() => 'https://todoist.com/oauth/authorize?test=1'),
exchangeCodeForToken: vi.fn(),
}))
vi.mock('../lib/oauth.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/oauth.js')>()
return {
...actual,
buildAuthorizationUrl: vi.fn(() => 'https://todoist.com/oauth/authorize?test=1'),
exchangeCodeForToken: vi.fn(),
}
})

// Mock open module
vi.mock('open', () => ({
Expand All @@ -58,17 +63,19 @@ import { createInterface, type Interface } from 'node:readline'
import open from 'open'
import { registerAuthCommand } from '../commands/auth/index.js'
import { getApi } from '../lib/api/core.js'
import { NoTokenError, clearApiToken, saveApiToken } from '../lib/auth.js'
import { NoTokenError, clearApiToken, getAuthMetadata, saveApiToken } from '../lib/auth.js'
import { startCallbackServer } from '../lib/oauth-server.js'
import { exchangeCodeForToken } from '../lib/oauth.js'
import { buildAuthorizationUrl, exchangeCodeForToken } from '../lib/oauth.js'
import { createMockApi } from './helpers/mock-api.js'

const mockCreateInterface = vi.mocked(createInterface)

const mockSaveApiToken = vi.mocked(saveApiToken)
const mockClearApiToken = vi.mocked(clearApiToken)
const mockGetAuthMetadata = vi.mocked(getAuthMetadata)
const mockGetApi = vi.mocked(getApi)
const mockStartCallbackServer = vi.mocked(startCallbackServer)
const mockBuildAuthorizationUrl = vi.mocked(buildAuthorizationUrl)
const mockExchangeCodeForToken = vi.mocked(exchangeCodeForToken)
const mockOpen = vi.mocked(open)

Expand Down Expand Up @@ -104,7 +111,7 @@ describe('auth command', () => {

await program.parseAsync(['node', 'td', 'auth', 'token', token])

expect(mockSaveApiToken).toHaveBeenCalledWith(token)
expect(mockSaveApiToken).toHaveBeenCalledWith(token, { authMode: 'unknown' })
expect(consoleSpy).toHaveBeenCalledWith('✓', 'API token saved successfully!')
expect(consoleSpy).toHaveBeenCalledWith(
'Token stored securely in the system credential manager',
Expand All @@ -121,7 +128,7 @@ describe('auth command', () => {
program.parseAsync(['node', 'td', 'auth', 'token', token]),
).rejects.toThrow('Permission denied')

expect(mockSaveApiToken).toHaveBeenCalledWith(token)
expect(mockSaveApiToken).toHaveBeenCalledWith(token, { authMode: 'unknown' })
})

it('trims whitespace from token', async () => {
Expand All @@ -133,7 +140,7 @@ describe('auth command', () => {

await program.parseAsync(['node', 'td', 'auth', 'token', tokenWithWhitespace])

expect(mockSaveApiToken).toHaveBeenCalledWith(expectedToken)
expect(mockSaveApiToken).toHaveBeenCalledWith(expectedToken, { authMode: 'unknown' })
})

it('prompts interactively when no token argument given', async () => {
Expand All @@ -153,7 +160,9 @@ describe('auth command', () => {

expect(mockRl.question).toHaveBeenCalled()
expect(mockRl.close).toHaveBeenCalled()
expect(mockSaveApiToken).toHaveBeenCalledWith('interactive_token_456')
expect(mockSaveApiToken).toHaveBeenCalledWith('interactive_token_456', {
authMode: 'unknown',
})
writeSpy.mockRestore()
})

Expand Down Expand Up @@ -235,13 +244,42 @@ describe('auth command', () => {
expect(mockOpen).toHaveBeenCalledWith('https://todoist.com/oauth/authorize?test=1')
expect(mockStartCallbackServer).toHaveBeenCalledWith('test_state')
expect(mockExchangeCodeForToken).toHaveBeenCalledWith(authCode, 'test_code_verifier')
expect(mockSaveApiToken).toHaveBeenCalledWith(accessToken)
expect(mockSaveApiToken).toHaveBeenCalledWith(accessToken, {
authMode: 'read-write',
authScope: 'data:read_write,data:delete,project:delete',
})
expect(consoleSpy).toHaveBeenCalledWith('✓', 'Successfully logged in!')
expect(consoleSpy).toHaveBeenCalledWith(
'Token stored securely in the system credential manager',
)
})

it('requests data:read scope when --read-only is set', async () => {
const program = createProgram()
const authCode = 'oauth_auth_code_123'
const accessToken = 'oauth_access_token_456'

mockStartCallbackServer.mockReturnValue({
promise: Promise.resolve(authCode),
cleanup: vi.fn(),
})
mockExchangeCodeForToken.mockResolvedValue(accessToken)
mockSaveApiToken.mockResolvedValue({ storage: 'secure-store' })
mockOpen.mockResolvedValue({} as Awaited<ReturnType<typeof open>>)

await program.parseAsync(['node', 'td', 'auth', 'login', '--read-only'])

expect(mockBuildAuthorizationUrl).toHaveBeenCalledWith(
'test_code_challenge',
'test_state',
{ readOnly: true },
)
expect(mockSaveApiToken).toHaveBeenCalledWith(accessToken, {
authMode: 'read-only',
authScope: 'data:read',
})
})

it('handles OAuth callback server error', async () => {
const program = createProgram()
const mockCleanup = vi.fn()
Expand Down Expand Up @@ -304,6 +342,11 @@ describe('auth command', () => {
const mockUser = { email: 'test@example.com', fullName: 'Test User' }
const mockApi = createMockApi({ getUser: vi.fn().mockResolvedValue(mockUser) })
mockGetApi.mockResolvedValue(mockApi)
mockGetAuthMetadata.mockResolvedValue({
authMode: 'read-write',
authScope: 'data:read_write,data:delete,project:delete',
source: 'secure-store',
})

await program.parseAsync(['node', 'td', 'auth', 'status'])

Expand All @@ -312,19 +355,47 @@ describe('auth command', () => {
expect(consoleSpy).toHaveBeenCalledWith('✓', 'Authenticated')
expect(consoleSpy).toHaveBeenCalledWith(' Email: test@example.com')
expect(consoleSpy).toHaveBeenCalledWith(' Name: Test User')
expect(consoleSpy).toHaveBeenCalledWith(' Mode: read-write')
})

it('shows read-only mode in status', async () => {
const program = createProgram()
const mockUser = { email: 'test@example.com', fullName: 'Test User' }
const mockApi = createMockApi({ getUser: vi.fn().mockResolvedValue(mockUser) })
mockGetApi.mockResolvedValue(mockApi)
mockGetAuthMetadata.mockResolvedValue({
authMode: 'read-only',
authScope: 'data:read',
source: 'secure-store',
})

await program.parseAsync(['node', 'td', 'auth', 'status'])

expect(consoleSpy).toHaveBeenCalledWith(' Mode: read-only (OAuth scope data:read)')
})

it('outputs JSON when --json flag is used', async () => {
const program = createProgram()
const mockUser = { id: '123', email: 'test@example.com', fullName: 'Test User' }
const mockApi = createMockApi({ getUser: vi.fn().mockResolvedValue(mockUser) })
mockGetApi.mockResolvedValue(mockApi)
mockGetAuthMetadata.mockResolvedValue({
authMode: 'read-write',
authScope: 'data:read_write,data:delete,project:delete',
source: 'secure-store',
})

await program.parseAsync(['node', 'td', 'auth', 'status', '--json'])

expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify(
{ id: '123', email: 'test@example.com', fullName: 'Test User' },
{
id: '123',
email: 'test@example.com',
fullName: 'Test User',
authMode: 'read-write',
authScope: 'data:read_write,data:delete,project:delete',
},
null,
2,
),
Expand Down
Loading
Loading