diff --git a/README.md b/README.md index 854620e..f0861b4 100644 --- a/README.md +++ b/README.md @@ -68,13 +68,13 @@ This opens Todoist OAuth in your browser. The default grant can read Comms data All group management — `groups create`, `rename`, `delete`, `add-user`, `remove-user` — needs the `workspaces:write` scope, so it requires `tdc auth login --full-access`. Group reads (`groups`, `groups view`) work on a default login. -Once approved, the token is stored in your OS credential manager: +By default, the CLI stores the token in your OS credential manager: - macOS: Keychain - Windows: Credential Manager - Linux: Secret Service/libsecret -If secure storage is unavailable, the CLI warns and falls back to `~/.config/comms-cli/config.json`. Non-secret settings such as the current workspace remain in the config file. +If secure storage is unavailable, the CLI warns and falls back to `~/.config/comms-cli/config.json`. Use `--credential-store system` to fail instead of writing plaintext, or `--credential-store plaintext` to choose config-file storage explicitly. Both `tdc auth login` and `tdc auth token` support this option. Non-secret settings such as the current workspace remain in the config file. ### Alternative methods @@ -168,11 +168,12 @@ tdc completion uninstall ## Machine-readable output -All list/view commands support `--json` and `--ndjson` flags for scripting: +List and view commands support `--json` and `--ndjson` for scripting. List commands with one clear entity ID also support `--ids-only`; check command help for the exact surface. ```bash tdc inbox --json # JSON array tdc inbox --ndjson # newline-delimited JSON +tdc inbox --ids-only # one thread ID per line tdc inbox --json --full # include all fields ``` diff --git a/package-lock.json b/package-lock.json index 1643c34..9d912d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@doist/cli-core": "0.26.2", + "@doist/cli-core": "1.4.0", "@doist/comms-sdk": "3.0.0", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", @@ -139,16 +139,17 @@ } }, "node_modules/@doist/cli-core": { - "version": "0.26.2", - "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-0.26.2.tgz", - "integrity": "sha512-9k72qqZsKeA9R2CiDmGgdiW37EPYRS7q6JVvn1Wjd/EbIy3N20DCGgRgDUqG0xql4c0mjX5QPGReje6dHN3WBw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-1.4.0.tgz", + "integrity": "sha512-8noRkEABCsqYFVRl+VviOSipX7O3iORy+OHTMEBtfSVuV3SAInFl8qC96KDl/VHurwoKar1mRLRu4DZ56pwmdw==", "license": "MIT", "dependencies": { "chalk": "5.6.2", - "yocto-spinner": "1.2.1" + "yocto-spinner": "1.2.2" }, "engines": { - "node": ">=20.18.1" + "node": ">=24", + "npm": ">=11" }, "optionalDependencies": { "@napi-rs/keyring": "1.3.0" @@ -231,6 +232,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -242,6 +244,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -252,6 +255,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -919,6 +923,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1814,6 +1819,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1830,6 +1836,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1846,6 +1853,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1862,6 +1870,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1878,6 +1887,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1894,6 +1904,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1910,6 +1921,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1926,6 +1938,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1942,6 +1955,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1958,6 +1972,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1974,6 +1989,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1990,6 +2006,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2006,6 +2023,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2024,6 +2042,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2040,6 +2059,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2642,6 +2662,7 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2683,7 +2704,7 @@ "version": "25.8.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -4299,6 +4320,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5195,6 +5217,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5215,6 +5238,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5235,6 +5259,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5255,6 +5280,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5275,6 +5301,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5295,6 +5322,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5315,6 +5343,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5335,6 +5364,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5355,6 +5385,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5375,6 +5406,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5395,6 +5427,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9678,6 +9711,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -9756,7 +9790,7 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -10289,9 +10323,9 @@ } }, "node_modules/yocto-spinner": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.1.tgz", - "integrity": "sha512-9cbFWLhbiZp+820O4pkHGNncI7+MrUGzBOjw8NMG+ewsY+aG0DdEXnr19Smxao32YOjLZRMdn1UtaxcrXOYOIg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz", + "integrity": "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==", "license": "MIT", "dependencies": { "yoctocolors": "^2.1.1" diff --git a/package.json b/package.json index 0ad718d..0a96d04 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "CHANGELOG.md" ], "dependencies": { - "@doist/cli-core": "0.26.2", + "@doist/cli-core": "1.4.0", "@doist/comms-sdk": "3.0.0", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index b8a7a44..870eaae 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -17,10 +17,13 @@ Access Comms messaging via the `tdc` CLI. Use when the user asks about their Com 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 --credential-store system # Require the system credential manager +tdc auth login --credential-store plaintext # Explicitly allow plaintext config storage tdc auth login --callback-port # 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 tdc auth token # Save API token manually (prompts securely; scope unknown, assumed write-capable) +tdc auth token --credential-store system # Require the system credential manager tdc auth status # Verify authentication + show mode tdc auth status --json # Full status payload as JSON (--ndjson also supported) tdc auth status --user # Target a specific stored account (id, id:, or display name) @@ -36,6 +39,7 @@ tdc account [list|current|use |remove ] # Manage stored accounts; all # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"token-only"} tdc auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set tdc workspaces # List available workspaces +tdc workspaces --ids-only # Output only workspace IDs tdc workspace use # Set current workspace tdc completion install # Install shell completions tdc config view # Show the current CLI configuration file (token masked) @@ -46,7 +50,7 @@ tdc changelog # Show recent changelog entries tdc migrate urls # Translate old twist.com URLs to Comms URLs (needs a Twist 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 (all `tdc groups` writes require it). 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 (all `tdc groups` writes require it). Credential storage defaults to `fallback`: use the system credential manager when available, otherwise warn and save to `~/.config/comms-cli/config.json`. Pass `--credential-store system` to fail instead of writing plaintext, or `--credential-store plaintext` to choose config-file storage explicitly. `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. @@ -76,6 +80,7 @@ tdc inbox --archive-filter archived # Show only done threads tdc inbox --channel # Filter by channel name (fuzzy) tdc inbox --since # Filter by date (ISO format) tdc inbox --limit # Max items (default: 50) +tdc inbox --ids-only # Output only thread IDs, one per line ``` ## Threads @@ -165,6 +170,8 @@ tdc conversation list --name "release" # Filter by title substring (case-ins tdc conversation list --state archived # Archived conversations only (active|all|archived; default active) tdc conversation list --snippet # Include the latest message snippet tdc conversation list --limit 20 --json # Cap rows and output as JSON +tdc conversation list --ids-only # Output only conversation IDs +tdc conversation unread --ids-only # Output only unread conversation IDs tdc conversation # View conversation (shorthand for view) tdc conversation view # View conversation messages tdc conversation with # Find your 1:1 DM with a user @@ -232,10 +239,12 @@ tdc user --json --full # Include all fields in JSON output tdc users # List active workspace users tdc users --search # Filter by name/email tdc users --include-removed # Include users removed from the workspace +tdc users --ids-only # Output only user IDs tdc channels # List active joined workspace channels (alias of: tdc channel list) tdc channels --state all # Include archived joined channels too tdc channels --scope discoverable # Active public channels you can see but have not joined tdc channels --scope public --state all --json # All visible public channels, with joined status +tdc channels --ids-only # Output only channel IDs tdc channel create "Engineering" # Create a channel in the current workspace tdc channel create "Leadership Team" --private --users id:10,id:20 # Create private channel with initial members tdc channel create "Product" --workspace "Doist" --description "Product discussions" --json # Create and return channel as JSON @@ -256,8 +265,10 @@ tdc channel threads --since 2026-01-01 # Filter by last-updated date (ISO) tdc channel threads --limit 20 # Max threads per page (default: 50) tdc channel threads --limit 20 --cursor # Paginate tdc channel threads --json # { results, nextCursor } with isUnread + url +tdc channel threads --ids-only # Output only thread IDs; pagination notice goes to stderr tdc channel members # List a channel's members + groups fully in the channel tdc channel members --json # JSON with id, name, workspaceId, members +tdc channel members --ids-only # Output only member user IDs tdc channel members add alice group:Design # Add users and/or expand group: members tdc channel members add a@d.com id:789 --json # Add refs, output result as JSON tdc channel members remove alice group:Frontend # Remove users and/or group members @@ -266,6 +277,7 @@ tdc channel members set alice bob # Dry-run by default; refuses to remove tdc groups # List workspace groups tdc groups --search "frontend" # Filter groups by name (case-insensitive) tdc groups --json # JSON output +tdc groups --ids-only # Output only group IDs tdc groups --json --full # Include all fields in JSON output tdc groups view # Show group with member details tdc groups view --json # JSON output with id, name, workspaceId, members @@ -390,11 +402,12 @@ Output is one line per URL in input order: `old -> new` on success, `old ✗ { // Mock the cli-core-backed token store so token / logout tests can drive // `set` / `clear` / `getLastStorageResult` / `getLastClearResult` directly. const storeMocks = vi.hoisted(() => ({ + factoryOptions: [] as Array< + | { + credentialStore?: + | 'system' + | 'plaintext' + | 'fallback' + | (() => 'system' | 'plaintext' | 'fallback') + } + | undefined + >, set: vi.fn(), clear: vi.fn(), active: vi.fn(), @@ -31,7 +41,10 @@ vi.mock('../../lib/auth-provider.js', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - createCommsTokenStore: () => storeMocks, + createCommsTokenStore: (options?: (typeof storeMocks.factoryOptions)[number]) => { + storeMocks.factoryOptions.push(options) + return storeMocks + }, } }) @@ -121,6 +134,7 @@ describe('auth command', () => { beforeEach(() => { vi.clearAllMocks() + storeMocks.factoryOptions.length = 0 // Mock console.log to capture output consoleSpy = captureConsole('log') @@ -213,6 +227,20 @@ describe('auth command', () => { ).rejects.toThrow('Permission denied') }) + it('passes --credential-store to the cli-core-backed token store', async () => { + mockPromptAnswer('some_token_123456789') + + await createProgram().parseAsync([ + 'node', + 'tdc', + 'auth', + 'token', + '--credential-store=plaintext', + ]) + + expect(storeMocks.factoryOptions).toContainEqual({ credentialStore: 'plaintext' }) + }) + it('surfaces the keyring-fallback warning from getLastStorageResult on stderr', async () => { storeMocks.getLastStorageResult.mockReturnValue({ storage: 'config-file', @@ -264,6 +292,51 @@ describe('auth command', () => { }) }) + it('updates the OAuth store policy from --credential-store', async () => { + await createProgram().parseAsync([ + 'node', + 'tdc', + 'auth', + 'login', + '--credential-store=system', + ]) + + const dynamic = storeMocks.factoryOptions.find( + (options) => typeof options?.credentialStore === 'function', + ) + expect(dynamic).toBeDefined() + const { credentialStore } = dynamic as NonNullable + expect((credentialStore as () => string)()).toBe('system') + }) + + it.each(['login', 'token'])( + 'rejects an invalid credential store on auth %s', + async (command) => { + await expect( + createProgram().parseAsync([ + 'node', + 'tdc', + 'auth', + command, + '--credential-store=typo', + ]), + ).rejects.toHaveProperty('code', 'INVALID_CREDENTIAL_STORE') + }, + ) + + it('registers credential-store values for shell completion', () => { + const auth = createProgram().commands.find((command) => command.name() === 'auth') + const login = auth?.commands.find((command) => command.name() === 'login') + const token = auth?.commands.find((command) => command.name() === 'token') + + expect( + login?.options.find((option) => option.long === '--credential-store')?.argChoices, + ).toEqual(['fallback', 'system', 'plaintext']) + expect( + token?.options.find((option) => option.long === '--credential-store')?.argChoices, + ).toEqual(['fallback', 'system', 'plaintext']) + }) + describe('token view subcommand', () => { let writeSpy: ReturnType diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index 8a29805..1ad34fe 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -1,7 +1,12 @@ import { attachRefreshTokenViewCommand, attachTokenViewCommand } from '@doist/cli-core/auth' -import { Command } from 'commander' -import { createCommsTokenStore } from '../../lib/auth-provider.js' +import { Command, Option } from 'commander' +import { + CREDENTIAL_STORES, + createCommsTokenStore, + parseCredentialStore, +} from '../../lib/auth-provider.js' import { TOKEN_ENV_VAR } from '../../lib/auth.js' +import { withUnvalidatedChoices } from '../../lib/completion.js' import { getRequestedUserRef } from '../../lib/global-args.js' import { attachCommsLoginCommand } from './login.js' import { attachCommsLogoutCommand } from './logout.js' @@ -15,7 +20,7 @@ export function registerAuthCommand(program: Command): void { const store = createCommsTokenStore() const refAware = withUserRefAware(store, getRequestedUserRef()) - attachCommsLoginCommand(auth, store) + attachCommsLoginCommand(auth) attachCommsLogoutCommand(auth, refAware) attachCommsStatusCommand(auth, refAware) @@ -26,7 +31,18 @@ export function registerAuthCommand(program: Command): void { const tokenCmd = auth .command('token') .description('Save API token for CLI authentication (or use a subcommand: `view`)') - .action(() => loginWithToken()) + .addOption( + withUnvalidatedChoices( + new Option( + '--credential-store ', + 'Credential storage: fallback (default), system, or plaintext', + ) + .argParser(parseCredentialStore) + .default('fallback'), + [...CREDENTIAL_STORES], + ), + ) + .action(loginWithToken) attachTokenViewCommand(tokenCmd, { name: 'view', diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 38e74e3..a5b6ac5 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -1,19 +1,25 @@ import { attachLoginCommand } from '@doist/cli-core/auth' import chalk from 'chalk' -import type { Command } from 'commander' +import { type Command, Option } from 'commander' import { renderError, renderSuccess } from '../../lib/auth-pages.js' import { createCommsAuthProvider, + CREDENTIAL_STORES, + type CommsCredentialStore, + createCommsTokenStore, getScopes, - type CommsTokenStore, + parseCredentialStore, } from '../../lib/auth-provider.js' +import { withUnvalidatedChoices } from '../../lib/completion.js' import { CliError } from '../../lib/errors.js' import { logTokenStorageResult, resetCurrentWorkspaceAfterLogin } from './helpers.js' const PREFERRED_CALLBACK_PORT = 8766 -export function attachCommsLoginCommand(parent: Command, store: CommsTokenStore): Command { +export function attachCommsLoginCommand(parent: Command): Command { const provider = createCommsAuthProvider() + let credentialStore: CommsCredentialStore = 'fallback' + const store = createCommsTokenStore({ credentialStore: () => credentialStore }) return attachLoginCommand(parent, { provider, @@ -49,4 +55,18 @@ export function attachCommsLoginCommand(parent: Command, store: CommsTokenStore) }) .description('Authenticate using OAuth (opens browser)') .option('--full-access', 'Request delete and workspace/user write scopes') + .addOption( + withUnvalidatedChoices( + new Option( + '--credential-store ', + 'Credential storage: fallback (default), system, or plaintext', + ) + .argParser((value: string) => { + credentialStore = parseCredentialStore(value) + return credentialStore + }) + .default('fallback'), + [...CREDENTIAL_STORES], + ), + ) } diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index b059fe0..4c67405 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -1,6 +1,10 @@ import { createInterface } from 'node:readline' import chalk from 'chalk' -import { createCommsTokenStore, MANUAL_TOKEN_ACCOUNT } from '../../lib/auth-provider.js' +import { + type CommsCredentialStore, + createCommsTokenStore, + MANUAL_TOKEN_ACCOUNT, +} from '../../lib/auth-provider.js' import { CliError } from '../../lib/errors.js' import { isNonInteractive } from '../../lib/global-args.js' import { logTokenStorageResult } from './helpers.js' @@ -31,7 +35,9 @@ function promptHiddenInput(prompt: string): Promise { // never accepted as a CLI argument. Passing secrets on the command line // would leak them via process lists and shell history (Doist Secrets // Management Standard). -export async function loginWithToken(): Promise { +export async function loginWithToken( + options: { credentialStore?: CommsCredentialStore } = {}, +): Promise { if (isNonInteractive()) { throw new CliError( 'NO_TOKEN', @@ -51,7 +57,7 @@ export async function loginWithToken(): Promise { // Persist the empty-id account; `UserRecordStore.upsert` writes // `authUserId: undefined` for it and the synthesised record is what later // `active()` / `list()` reads will return. - const store = createCommsTokenStore() + const store = createCommsTokenStore({ credentialStore: options.credentialStore }) await store.set(MANUAL_TOKEN_ACCOUNT, trimmed) console.log(chalk.green('✓'), 'API token saved successfully!') const result = store.getLastStorageResult() diff --git a/src/commands/channel/channel.test.ts b/src/commands/channel/channel.test.ts index 890718f..5a277ea 100644 --- a/src/commands/channel/channel.test.ts +++ b/src/commands/channel/channel.test.ts @@ -163,6 +163,28 @@ describe('channels list', () => { }) }) + it('outputs one stable channel ID per line with --ids-only', async () => { + const client = createClient({ + joinedChannels: [createChannel(10, 'General'), createChannel(20, 'Product')], + }) + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await createProgram().parseAsync(['node', 'tdc', 'channels', '--ids-only']) + + expect(consoleSpy).toHaveBeenCalledWith('10\n20') + }) + + it('rejects conflicting machine-output modes before fetching channels', async () => { + const client = createClient() + apiMocks.getCommsClient.mockResolvedValue(client) + + await expect( + createProgram().parseAsync(['node', 'tdc', 'channels', '--ids-only', '--json']), + ).rejects.toThrow('Options --json, --ids-only are mutually exclusive.') + expect(apiMocks.getCommsClient).not.toHaveBeenCalled() + }) + it('includes joined private channels when --include-private-channels is enabled', async () => { globalArgsMocks.includePrivateChannels.mockReturnValue(true) const client = createClient({ @@ -357,6 +379,7 @@ describe('channels list', () => { await program.parseAsync(['node', 'tdc', 'channels', ...extraArgs]) }, humanMessage: 'No active channels found.', + idsOnly: true, }) it('shows a specific empty state when no active discoverable channels remain', async () => { diff --git a/src/commands/channel/index.ts b/src/commands/channel/index.ts index 6b53d14..602501f 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -31,6 +31,7 @@ export function registerChannelCommand(program: Command): void { ) .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') + .option('--ids-only', 'Output only channel IDs, one per line') .option('--full', 'Include all fields in JSON output') .addHelpText( 'after', @@ -177,6 +178,7 @@ Notes: .option('--cursor ', 'Pagination cursor from a previous response') .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') + .option('--ids-only', 'Output only thread IDs, one per line') .option('--full', 'Include all fields in JSON output') .addHelpText( 'after', @@ -204,6 +206,7 @@ Notes: .description("List a channel's members and groups fully present in the channel") .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') + .option('--ids-only', 'Output only member user IDs, one per line') .option('--full', 'Include all fields in JSON output') .addHelpText( 'after', diff --git a/src/commands/channel/list.ts b/src/commands/channel/list.ts index b9ab69a..7d4e112 100644 --- a/src/commands/channel/list.ts +++ b/src/commands/channel/list.ts @@ -1,9 +1,10 @@ +import { outputIds, printEmpty, resolveOutputMode } from '@doist/cli-core' import type { Channel } from '@doist/comms-sdk' import { getCommsClient } from '../../lib/api.js' import { CliError } from '../../lib/errors.js' import { includePrivateChannels } from '../../lib/global-args.js' import type { ViewOptions } from '../../lib/options.js' -import { colors, formatJson, formatNdjson, printEmpty } from '../../lib/output.js' +import { colors, formatJson, formatNdjson } from '../../lib/output.js' import { resolveChannelWorkspaceId } from './helpers.js' const CHANNEL_SCOPES = ['joined', 'public', 'discoverable'] as const @@ -144,6 +145,7 @@ export async function listChannels( workspaceRef: string | undefined, options: ListChannelsOptions, ): Promise { + const outputMode = resolveOutputMode(options) const scope = parseChannelScope(options.scope) const state = parseChannelState(options.state) const workspaceId = await getWorkspaceId(workspaceRef, options) @@ -183,16 +185,21 @@ export async function listChannels( } if (channels.length === 0) { - printEmpty({ options, type: 'channel', message: getEmptyStateMessage(scope, state) }) + printEmpty({ options, message: getEmptyStateMessage(scope, state) }) return } - if (options.json) { + if (outputMode === 'ids-only') { + await outputIds(channels, (channel) => channel.id) + return + } + + if (outputMode === 'json') { console.log(formatListedChannelsJson(channels, scope, options.full)) return } - if (options.ndjson) { + if (outputMode === 'ndjson') { console.log(formatListedChannelsNdjson(channels, scope, options.full)) return } diff --git a/src/commands/channel/members.test.ts b/src/commands/channel/members.test.ts index c4b55d0..062c2fb 100644 --- a/src/commands/channel/members.test.ts +++ b/src/commands/channel/members.test.ts @@ -76,6 +76,24 @@ beforeEach(() => { }) describe('tdc channel members list (default)', () => { + it('outputs member IDs without fetching groups or user details', async () => { + refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2, 99])) + const consoleSpy = captureConsole('log') + + await createProgram().parseAsync([ + 'node', + 'tdc', + 'channel', + 'members', + 'General', + '--ids-only', + ]) + + expect(consoleSpy).toHaveBeenCalledWith('1\n2\n99') + expect(apiMocks.getWorkspaceGroups).not.toHaveBeenCalled() + expect(apiMocks.getCommsClient).not.toHaveBeenCalled() + }) + it('lists members with names/emails and groups fully in channel', async () => { refsMocks.resolveChannelRef.mockResolvedValue(createChannel([1, 2, 3])) const consoleSpy = captureConsole('log') diff --git a/src/commands/channel/members.ts b/src/commands/channel/members.ts index 774240e..cd60b85 100644 --- a/src/commands/channel/members.ts +++ b/src/commands/channel/members.ts @@ -1,3 +1,4 @@ +import { outputIds, resolveOutputMode } from '@doist/cli-core' import { isRestrictedWorkspaceUser } from '@doist/comms-sdk' import { getCurrentWorkspaceId, getWorkspaceGroups } from '../../lib/api.js' import type { ViewOptions } from '../../lib/options.js' @@ -9,11 +10,18 @@ export async function listChannelMembers( channelRef: string, options: ViewOptions & { full?: boolean }, ): Promise { + const outputMode = resolveOutputMode(options) const workspaceId = await getCurrentWorkspaceId() - const [channel, groups] = await Promise.all([ - resolveChannelRef(channelRef, workspaceId), - getWorkspaceGroups(workspaceId), - ]) + const channelPromise = resolveChannelRef(channelRef, workspaceId) + + if (outputMode === 'ids-only') { + const channel = await channelPromise + const userIds = channelUserIds(channel) + await outputIds(userIds, (id) => id) + return + } + + const [channel, groups] = await Promise.all([channelPromise, getWorkspaceGroups(workspaceId)]) const userIds = channelUserIds(channel) const userMap = await fetchUsersByIds(workspaceId, userIds) @@ -39,12 +47,12 @@ export async function listChannelMembers( } const fullPayload = { ...channel, members, groupsFullyInChannel: fullyInChannel } - if (options.json) { + if (outputMode === 'json') { console.log(formatJson(options.full ? fullPayload : slimPayload)) return } - if (options.ndjson) { + if (outputMode === 'ndjson') { console.log(formatNdjson([options.full ? fullPayload : slimPayload])) return } diff --git a/src/commands/channel/threads.test.ts b/src/commands/channel/threads.test.ts index dfbef11..aaf9cae 100644 --- a/src/commands/channel/threads.test.ts +++ b/src/commands/channel/threads.test.ts @@ -345,6 +345,35 @@ describe('channel threads', () => { expect(output.nextCursor).toEqual(encodeCursor(2)) }) + it('--ids-only keeps pagination notices on stderr and skips unread enrichment', async () => { + const { mockGetUnread } = setupClient({ + threads: [ + createThread(1, { lastUpdated: new Date('2026-01-03T00:00:00Z') }), + createThread(2, { lastUpdated: new Date('2026-01-02T00:00:00Z') }), + createThread(3, { lastUpdated: new Date('2026-01-01T00:00:00Z') }), + ], + }) + const consoleSpy = captureConsole('log') + const errorSpy = captureConsole('error') + + await createProgram().parseAsync([ + 'node', + 'tdc', + 'channel', + 'threads', + '12345', + '--ids-only', + '--limit', + '2', + ]) + + expect(consoleSpy).toHaveBeenCalledWith('1\n2') + expect(errorSpy).toHaveBeenCalledWith( + `More threads available. Use --cursor ${encodeCursor(2)}`, + ) + expect(mockGetUnread).not.toHaveBeenCalled() + }) + it('--cursor advances to the next page', async () => { setupClient({ threads: [ diff --git a/src/commands/channel/threads.ts b/src/commands/channel/threads.ts index b83613b..8570e67 100644 --- a/src/commands/channel/threads.ts +++ b/src/commands/channel/threads.ts @@ -1,3 +1,4 @@ +import { outputIds, resolveOutputMode } from '@doist/cli-core' import type { ArchiveFilter, Thread } from '@doist/comms-sdk' import chalk from 'chalk' import { getCommsClient, getCurrentWorkspaceId } from '../../lib/api.js' @@ -52,6 +53,7 @@ export async function showChannelThreads( workspaceRef: string | undefined, options: ChannelThreadsOptions, ): Promise { + const outputMode = resolveOutputMode(options) if (workspaceRef && options.workspace) { throw new CliError( 'CONFLICTING_OPTIONS', @@ -77,22 +79,22 @@ export async function showChannelThreads( const archived = archiveFilterToFlag(options.archiveFilter) const client = await getCommsClient() + const needsUnreadData = outputMode !== 'ids-only' || options.unread const [threadsData, unreadThreadIds] = await Promise.all([ client.threads.getThreads( archived === undefined ? { workspaceId, channelId: channel.id } : { workspaceId, channelId: channel.id, archived }, ), - fetchUnreadThreadIds(client, workspaceId), + needsUnreadData + ? fetchUnreadThreadIds(client, workspaceId) + : Promise.resolve(new Set()), ]) - let threads: DecoratedThread[] = threadsData.map((t) => ({ - ...t, - isUnread: unreadThreadIds.has(t.id), - })) + let threads = threadsData if (options.unread) { - threads = threads.filter((t) => t.isUnread) + threads = threads.filter((thread) => unreadThreadIds.has(thread.id)) } if (sinceTs !== undefined) { @@ -111,19 +113,35 @@ export async function showChannelThreads( const page = threads.slice(offset, offset + limit) const nextCursor = offset + limit < threads.length ? encodeCursor(offset + limit) : null - const paginated: PaginatedOutput = { results: page, nextCursor } + if (outputMode === 'ids-only') { + await outputIds( + page, + (thread) => thread.id, + nextCursor ? `More threads available. Use --cursor ${nextCursor}` : '', + ) + return + } + + const decoratedPage: DecoratedThread[] = page.map((thread) => ({ + ...thread, + isUnread: unreadThreadIds.has(thread.id), + })) + const paginated: PaginatedOutput = { + results: decoratedPage, + nextCursor, + } - if (options.json) { + if (outputMode === 'json') { console.log(formatPaginatedJson(paginated, 'thread', options.full)) return } - if (options.ndjson) { + if (outputMode === 'ndjson') { console.log(formatPaginatedNdjson(paginated, 'thread', options.full)) return } - if (page.length === 0) { + if (decoratedPage.length === 0) { console.log(`No threads in #${channel.name}.`) return } @@ -131,7 +149,7 @@ export async function showChannelThreads( console.log(chalk.bold.blue(`[${channel.name}]`)) console.log('') - for (const thread of page) { + for (const thread of decoratedPage) { const title = thread.isUnread ? chalk.bold(thread.title) : thread.title const time = colors.timestamp(formatRelativeDate(thread.lastUpdated)) const unreadBadge = thread.isUnread ? chalk.blue(isAccessible() ? ' (unread)' : ' *') : '' diff --git a/src/commands/conversation/conversation.test.ts b/src/commands/conversation/conversation.test.ts index 416d545..16dfc80 100644 --- a/src/commands/conversation/conversation.test.ts +++ b/src/commands/conversation/conversation.test.ts @@ -232,6 +232,24 @@ describe('conversation unread --workspace conflict', () => { }) }) +describe('conversation unread --ids-only', () => { + it('outputs IDs without fetching or enriching full conversations', async () => { + const client = createClient({}) + client.conversations.getUnread.mockResolvedValue({ + data: [{ conversationId: '42' }, { conversationId: '43' }], + version: 1, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await createProgram().parseAsync(['node', 'tdc', 'conversation', 'unread', '--ids-only']) + + expect(consoleSpy).toHaveBeenCalledWith('42\n43') + expect(client.conversations.getConversation).not.toHaveBeenCalled() + expect(client.workspaceUsers.getWorkspaceUsers).not.toHaveBeenCalled() + }) +}) + describeEmptyMachineOutput('conversation unread empty output', { setup: () => { vi.clearAllMocks() @@ -243,6 +261,7 @@ describeEmptyMachineOutput('conversation unread empty output', { await program.parseAsync(['node', 'tdc', 'conversation', 'unread', ...extraArgs]) }, humanMessage: 'No unread conversations.', + idsOnly: true, }) describe('conversation with', () => { @@ -251,6 +270,24 @@ describe('conversation with', () => { refsMocks.resolveUserRefs.mockResolvedValue([2]) }) + it('rejects conflicting output modes before resolving refs or making API calls', async () => { + await expect( + createProgram().parseAsync([ + 'node', + 'tdc', + 'conversation', + 'with', + 'Alice', + '--json', + '--ndjson', + ]), + ).rejects.toThrow('Options --json, --ndjson are mutually exclusive.') + + expect(apiMocks.getCurrentWorkspaceId).not.toHaveBeenCalled() + expect(refsMocks.resolveUserRefs).not.toHaveBeenCalled() + expect(apiMocks.getCommsClient).not.toHaveBeenCalled() + }) + it('prints the exact 1:1 conversation for a user', async () => { const directConversation = createConversation(42, [1, 2], '2026-03-08T10:00:00.000Z') const groupConversation = createConversation(43, [1, 2, 3], '2026-03-09T10:00:00.000Z') @@ -452,6 +489,23 @@ describe('conversation list', () => { ) }) + it('outputs one stable conversation ID per line without user enrichment', async () => { + const client = createClient({ + activeConversations: [ + titled(42, [1, 2], '2026-03-08T10:00:00.000Z', 'Older direct'), + titled(43, [1, 2, 3], '2026-03-09T10:00:00.000Z', 'Newer group'), + ], + users: standardUsers, + }) + apiMocks.getCommsClient.mockResolvedValue(client) + const consoleSpy = captureConsole('log') + + await createProgram().parseAsync(['node', 'tdc', 'conversation', 'list', '--ids-only']) + + expect(consoleSpy).toHaveBeenCalledWith('43\n42') + expect(client.workspaceUsers.getWorkspaceUsers).not.toHaveBeenCalled() + }) + it('filters to conversations that include a given participant', async () => { const client = createClient({ activeConversations: [ diff --git a/src/commands/conversation/helpers.ts b/src/commands/conversation/helpers.ts index d3e4d6a..455573d 100644 --- a/src/commands/conversation/helpers.ts +++ b/src/commands/conversation/helpers.ts @@ -1,3 +1,4 @@ +import { outputIds, printEmpty, resolveOutputMode } from '@doist/cli-core' import type { Conversation } from '@doist/comms-sdk' import chalk from 'chalk' import { buildUserNameMap, getCommsClient } from '../../lib/api.js' @@ -6,7 +7,7 @@ import { CliError } from '../../lib/errors.js' import { isAccessible } from '../../lib/global-args.js' import { renderMarkdown } from '../../lib/markdown.js' import type { MutationOptions, PaginatedViewOptions, ViewOptions } from '../../lib/options.js' -import { colors, formatJson, formatNdjson, printEmpty } from '../../lib/output.js' +import { colors, formatJson, formatNdjson } from '../../lib/output.js' export type UnreadOptions = ViewOptions & { workspace?: string } @@ -32,6 +33,7 @@ export type ConversationListOptions = ViewOptions & { export type ConversationRenderOptions = { json?: boolean ndjson?: boolean + idsOnly?: boolean full?: boolean snippet?: boolean } @@ -187,19 +189,24 @@ export async function renderConversationList( workspaceId: number, options: ConversationRenderOptions, ): Promise { + const outputMode = resolveOutputMode(options) if (conversations.length === 0) { printEmpty({ options, - type: 'conversation', message: 'No matching conversations found.', }) return } + if (outputMode === 'ids-only') { + await outputIds(conversations, (conversation) => conversation.id) + return + } + // Machine output without --full filters `participantNames` back out, so skip // the workspace-wide user-map fetch whose names would never be emitted. - if ((options.json || options.ndjson) && !options.full) { - if (options.json) { + if ((outputMode === 'json' || outputMode === 'ndjson') && !options.full) { + if (outputMode === 'json') { console.log(formatJson(conversations, 'conversation', false)) } else { console.log(formatNdjson(conversations, 'conversation', false)) @@ -215,12 +222,12 @@ export async function renderConversationList( participantNames: conversation.userIds.map((id) => userMap.get(id)), })) - if (options.json) { + if (outputMode === 'json') { console.log(formatJson(output, 'conversation', options.full)) return } - if (options.ndjson) { + if (outputMode === 'ndjson') { console.log(formatNdjson(output, 'conversation', options.full)) return } diff --git a/src/commands/conversation/index.ts b/src/commands/conversation/index.ts index 2f152d3..339e702 100644 --- a/src/commands/conversation/index.ts +++ b/src/commands/conversation/index.ts @@ -22,6 +22,7 @@ export function registerConversationCommand(program: Command): void { .option('--workspace ', 'Workspace ID or name') .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') + .option('--ids-only', 'Output only conversation IDs, one per line') .option('--full', 'Include all fields in JSON output') .addHelpText( 'after', @@ -60,6 +61,7 @@ Examples: .option('--limit ', 'Maximum conversations to show (default: all)') .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') + .option('--ids-only', 'Output only conversation IDs, one per line') .option('--full', 'Include all fields in JSON output') .addHelpText( 'after', diff --git a/src/commands/conversation/list.ts b/src/commands/conversation/list.ts index f515458..723f134 100644 --- a/src/commands/conversation/list.ts +++ b/src/commands/conversation/list.ts @@ -1,3 +1,4 @@ +import { resolveOutputMode } from '@doist/cli-core' import { getCurrentWorkspaceId } from '../../lib/api.js' import { CliError } from '../../lib/errors.js' import { resolveUserRefs, resolveWorkspaceRef } from '../../lib/refs.js' @@ -37,6 +38,9 @@ export async function listConversations( workspaceRef: string | undefined, options: ConversationListOptions, ): Promise { + // Validate before resolving refs or fetching conversations. The renderer + // resolves again because it is also shared by `conversation with`. + resolveOutputMode(options) if (workspaceRef && options.workspace) { throw new CliError( 'CONFLICTING_OPTIONS', diff --git a/src/commands/conversation/unread.ts b/src/commands/conversation/unread.ts index a141aff..2322e8b 100644 --- a/src/commands/conversation/unread.ts +++ b/src/commands/conversation/unread.ts @@ -1,8 +1,9 @@ +import { outputIds, printEmpty, resolveOutputMode } from '@doist/cli-core' import chalk from 'chalk' import { buildUserNameMap, getCommsClient, getCurrentWorkspaceId } from '../../lib/api.js' import { CliError } from '../../lib/errors.js' import { isAccessible } from '../../lib/global-args.js' -import { colors, formatJson, formatNdjson, printEmpty } from '../../lib/output.js' +import { colors, formatJson, formatNdjson } from '../../lib/output.js' import { resolveWorkspaceRef } from '../../lib/refs.js' import type { UnreadOptions } from './helpers.js' @@ -10,6 +11,7 @@ export async function showUnread( workspaceRef: string | undefined, options: UnreadOptions, ): Promise { + const outputMode = resolveOutputMode(options) if (workspaceRef && options.workspace) { throw new CliError( 'CONFLICTING_OPTIONS', @@ -32,7 +34,12 @@ export async function showUnread( const unreadConversations = unreadResponse.data if (unreadConversations.length === 0) { - printEmpty({ options, type: 'conversation', message: 'No unread conversations.' }) + printEmpty({ options, message: 'No unread conversations.' }) + return + } + + if (outputMode === 'ids-only') { + await outputIds(unreadConversations, (conversation) => conversation.conversationId) return } @@ -42,7 +49,7 @@ export async function showUnread( const userMap = await buildUserNameMap(workspaceId, client) - if (options.json) { + if (outputMode === 'json') { const output = conversations.map((c) => ({ ...c, participantNames: c.userIds.map((id) => userMap.get(id)), @@ -51,7 +58,7 @@ export async function showUnread( return } - if (options.ndjson) { + if (outputMode === 'ndjson') { const output = conversations.map((c) => ({ ...c, participantNames: c.userIds.map((id) => userMap.get(id)), diff --git a/src/commands/conversation/with.ts b/src/commands/conversation/with.ts index 4bb6633..a305caa 100644 --- a/src/commands/conversation/with.ts +++ b/src/commands/conversation/with.ts @@ -1,3 +1,4 @@ +import { resolveOutputMode } from '@doist/cli-core' import { getCurrentWorkspaceId, getSessionUser, getCommsClient } from '../../lib/api.js' import { CliError } from '../../lib/errors.js' import { resolveUserRefs, resolveWorkspaceRef } from '../../lib/refs.js' @@ -13,6 +14,7 @@ export async function findConversationWithUser( workspaceRef: string | undefined, options: ConversationWithOptions, ): Promise { + resolveOutputMode(options) if (workspaceRef && options.workspace) { throw new CliError( 'CONFLICTING_OPTIONS', diff --git a/src/commands/groups/groups.test.ts b/src/commands/groups/groups.test.ts index 7dbefaa..e6bdeda 100644 --- a/src/commands/groups/groups.test.ts +++ b/src/commands/groups/groups.test.ts @@ -90,6 +90,7 @@ describeEmptyMachineOutput('tdc groups list empty output', { await program.parseAsync(['node', 'tdc', 'groups', ...extraArgs]) }, humanMessage: 'No groups found.', + idsOnly: true, }) describe('tdc groups list (default)', () => { diff --git a/src/commands/groups/index.ts b/src/commands/groups/index.ts index 8379535..94e36a0 100644 --- a/src/commands/groups/index.ts +++ b/src/commands/groups/index.ts @@ -16,6 +16,7 @@ export function registerGroupsCommand(program: Command): void { .option('--search ', 'Filter by name') .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') + .option('--ids-only', 'Output only group IDs, one per line') .option('--full', 'Include all fields in JSON output') .addHelpText( 'after', diff --git a/src/commands/groups/list.ts b/src/commands/groups/list.ts index e82917a..05fab8b 100644 --- a/src/commands/groups/list.ts +++ b/src/commands/groups/list.ts @@ -1,7 +1,8 @@ +import { outputIds, printEmpty, resolveOutputMode } from '@doist/cli-core' import { getCurrentWorkspaceId, getWorkspaceGroups } from '../../lib/api.js' import { CliError } from '../../lib/errors.js' import type { ViewOptions } from '../../lib/options.js' -import { colors, formatJson, formatNdjson, pluralize, printEmpty } from '../../lib/output.js' +import { colors, formatJson, formatNdjson, pluralize } from '../../lib/output.js' import { resolveWorkspaceRef } from '../../lib/refs.js' export type ListGroupsOptions = ViewOptions & { workspace?: string; search?: string } @@ -10,6 +11,7 @@ export async function listGroups( workspaceRef: string | undefined, options: ListGroupsOptions, ): Promise { + const outputMode = resolveOutputMode(options) if (workspaceRef && options.workspace) { throw new CliError( 'CONFLICTING_OPTIONS', @@ -35,16 +37,21 @@ export async function listGroups( } if (groups.length === 0) { - printEmpty({ options, type: 'group', message: 'No groups found.' }) + printEmpty({ options, message: 'No groups found.' }) return } - if (options.json) { + if (outputMode === 'ids-only') { + await outputIds(groups, (group) => group.id) + return + } + + if (outputMode === 'json') { console.log(formatJson(groups, 'group', options.full)) return } - if (options.ndjson) { + if (outputMode === 'ndjson') { console.log(formatNdjson(groups, 'group', options.full)) return } diff --git a/src/commands/inbox.test.ts b/src/commands/inbox.test.ts index 3175fde..7b36b37 100644 --- a/src/commands/inbox.test.ts +++ b/src/commands/inbox.test.ts @@ -127,6 +127,38 @@ describeEmptyMachineOutput('inbox empty output', { await program.parseAsync(['node', 'tdc', 'inbox', ...extraArgs]) }, humanMessage: 'No threads in inbox.', + idsOnly: true, +}) + +describe('inbox --ids-only', () => { + it('outputs thread IDs without fetching channel names when no channel filter is set', async () => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + const { getChannel } = mockClient({ + inboxThreads: [ + { + id: 'thread-1', + channelId: 'channel-1', + title: 'First', + posted: '2026-05-01T00:00:00Z', + url: 'https://example.test/thread-1', + }, + { + id: 'thread-2', + channelId: 'channel-2', + title: 'Second', + posted: '2026-05-02T00:00:00Z', + url: 'https://example.test/thread-2', + }, + ], + }) + const consoleSpy = captureConsole('log') + + await createProgram().parseAsync(['node', 'tdc', 'inbox', '--ids-only']) + + expect(consoleSpy).toHaveBeenCalledWith('thread-1\nthread-2') + expect(getChannel).not.toHaveBeenCalled() + }) }) describe('inbox empty output (channel filter)', () => { diff --git a/src/commands/inbox.ts b/src/commands/inbox.ts index d98aac1..b166bb4 100644 --- a/src/commands/inbox.ts +++ b/src/commands/inbox.ts @@ -1,3 +1,4 @@ +import { outputIds, printEmpty, resolveOutputMode } from '@doist/cli-core' import type { ArchiveFilter } from '@doist/comms-sdk' import chalk from 'chalk' import { Command, Option } from 'commander' @@ -7,7 +8,7 @@ import { formatRelativeDate } from '../lib/dates.js' import { CliError } from '../lib/errors.js' import { includePrivateChannels, isAccessible } from '../lib/global-args.js' import { toDate, type PaginatedViewOptions } from '../lib/options.js' -import { colors, formatJson, formatNdjson, printEmpty } from '../lib/output.js' +import { colors, formatJson, formatNdjson } from '../lib/output.js' import { getPublicChannelIds } from '../lib/public-channels.js' import { resolveWorkspaceRef } from '../lib/refs.js' import { fetchUnreadThreadIds } from '../lib/threads.js' @@ -20,6 +21,7 @@ type InboxOptions = PaginatedViewOptions & { } async function showInbox(workspaceRef: string | undefined, options: InboxOptions): Promise { + const outputMode = resolveOutputMode(options) if (workspaceRef && options.workspace) { throw new CliError( 'CONFLICTING_OPTIONS', @@ -61,26 +63,29 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions } if (inboxThreads.length === 0) { - printEmpty({ options, type: 'thread', message: 'No threads in inbox.' }) + printEmpty({ options, message: 'No threads in inbox.' }) return } - const channelIds = [...new Set(inboxThreads.map((t) => t.channelId))] - const channelEntries = await Promise.all( - channelIds.map(async (id) => [id, await client.channels.getChannel(id)] as const), - ) - const channelMap = new Map(channelEntries.map(([id, ch]) => [id, ch.name])) - if (!includePrivateChannels()) { const publicIds = await getPublicChannelIds(workspaceId) inboxThreads = inboxThreads.filter((t) => publicIds.has(t.channelId)) if (inboxThreads.length === 0) { - printEmpty({ options, type: 'thread', message: 'No threads in public channels.' }) + printEmpty({ options, message: 'No threads in public channels.' }) return } } + let channelMap = new Map() + if (outputMode !== 'ids-only' || options.channel) { + const channelIds = [...new Set(inboxThreads.map((t) => t.channelId))] + const channelEntries = await Promise.all( + channelIds.map(async (id) => [id, await client.channels.getChannel(id)] as const), + ) + channelMap = new Map(channelEntries.map(([id, channel]) => [id, channel.name])) + } + if (options.channel) { const filter = options.channel.toLowerCase() const matchingChannelIds = new Set( @@ -93,7 +98,6 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions if (inboxThreads.length === 0) { printEmpty({ options, - type: 'thread', message: `No threads in channels matching "${options.channel}".`, }) return @@ -118,7 +122,12 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions sortedChannelGroups.push(...unreads, ...reads) } - if (options.json) { + if (outputMode === 'ids-only') { + await outputIds(sortedChannelGroups, (thread) => thread.id) + return + } + + if (outputMode === 'json') { const output = sortedChannelGroups.map((t) => ({ ...t, channelName: channelMap.get(t.channelId), @@ -127,7 +136,7 @@ async function showInbox(workspaceRef: string | undefined, options: InboxOptions return } - if (options.ndjson) { + if (outputMode === 'ndjson') { const output = sortedChannelGroups.map((t) => ({ ...t, channelName: channelMap.get(t.channelId), @@ -178,6 +187,7 @@ export function registerInboxCommand(program: Command): void { .option('--limit ', 'Max items (default: 50)') .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') + .option('--ids-only', 'Output only thread IDs, one per line') .option('--full', 'Include all fields in JSON output') .addHelpText( 'after', diff --git a/src/commands/user.test.ts b/src/commands/user.test.ts index ebde3ee..9dfb97a 100644 --- a/src/commands/user.test.ts +++ b/src/commands/user.test.ts @@ -54,6 +54,7 @@ describeEmptyMachineOutput('tdc users empty output', { await program.parseAsync(['node', 'tdc', 'users', ...extraArgs]) }, humanMessage: 'No users found.', + idsOnly: true, }) describe('tdc users --include-removed', () => { @@ -101,6 +102,15 @@ describe('tdc users --include-removed', () => { expect(lines).not.toMatch(/id:1.*Active.*\[removed\]/) }) + it('outputs one stable user ID per line with --ids-only', async () => { + apiMocks.getWorkspaceUsers.mockResolvedValueOnce([active, removed]) + const consoleSpy = captureConsole('log') + + await createProgram().parseAsync(['node', 'tdc', 'users', '--ids-only']) + + expect(consoleSpy).toHaveBeenCalledWith('1\n2') + }) + it('surfaces removed in curated --json output without --full', async () => { apiMocks.getWorkspaceUsers.mockResolvedValueOnce([active, removed]) const program = createProgram() diff --git a/src/commands/user.ts b/src/commands/user.ts index 703362b..aa1e4ed 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,3 +1,4 @@ +import { outputIds, printEmpty, resolveOutputMode } from '@doist/cli-core' import chalk from 'chalk' import { Command } from 'commander' import { @@ -8,7 +9,7 @@ import { } from '../lib/api.js' import { CliError } from '../lib/errors.js' import type { ViewOptions } from '../lib/options.js' -import { colors, formatJson, formatNdjson, printEmpty } from '../lib/output.js' +import { colors, formatJson, formatNdjson } from '../lib/output.js' import { resolveWorkspaceRef } from '../lib/refs.js' type UsersOptions = ViewOptions & { @@ -41,6 +42,7 @@ async function showCurrentUser(options: ViewOptions): Promise { } async function listUsers(workspaceRef: string | undefined, options: UsersOptions): Promise { + const outputMode = resolveOutputMode(options) if (workspaceRef && options.workspace) { throw new CliError( 'CONFLICTING_OPTIONS', @@ -70,16 +72,21 @@ async function listUsers(workspaceRef: string | undefined, options: UsersOptions } if (users.length === 0) { - printEmpty({ options, type: 'user', message: 'No users found.' }) + printEmpty({ options, message: 'No users found.' }) return } - if (options.json) { + if (outputMode === 'ids-only') { + await outputIds(users, (user) => user.id) + return + } + + if (outputMode === 'json') { console.log(formatJson(users, 'user', options.full)) return } - if (options.ndjson) { + if (outputMode === 'ndjson') { console.log(formatNdjson(users, 'user', options.full)) return } @@ -117,6 +124,7 @@ Examples: .option('--include-removed', 'Include users who have been removed from the workspace') .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') + .option('--ids-only', 'Output only user IDs, one per line') .option('--full', 'Include all fields in JSON output') .addHelpText( 'after', diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index d121c37..161e3a0 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -1,27 +1,34 @@ +import { outputIds, printEmpty, resolveOutputMode } from '@doist/cli-core' import chalk from 'chalk' import { Command } from 'commander' import { fetchWorkspaces, getCurrentWorkspaceId } from '../lib/api.js' import { updateConfig } from '../lib/config.js' import type { ViewOptions } from '../lib/options.js' -import { colors, formatJson, formatNdjson, printEmpty } from '../lib/output.js' +import { colors, formatJson, formatNdjson } from '../lib/output.js' import { resolveWorkspaceRef } from '../lib/refs.js' type ListOptions = ViewOptions async function listWorkspaces(options: ListOptions): Promise { + const outputMode = resolveOutputMode(options) const workspaces = await fetchWorkspaces() if (workspaces.length === 0) { - printEmpty({ options, type: 'workspace', message: 'No workspaces found.' }) + printEmpty({ options, message: 'No workspaces found.' }) return } - if (options.json) { + if (outputMode === 'ids-only') { + await outputIds(workspaces, (workspace) => workspace.id) + return + } + + if (outputMode === 'json') { console.log(formatJson(workspaces, 'workspace', options.full)) return } - if (options.ndjson) { + if (outputMode === 'ndjson') { console.log(formatNdjson(workspaces, 'workspace', options.full)) return } @@ -49,6 +56,7 @@ export function registerWorkspaceCommand(program: Command): void { .description('List all workspaces') .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') + .option('--ids-only', 'Output only workspace IDs, one per line') .option('--full', 'Include all fields in JSON output') .addHelpText( 'after', diff --git a/src/index.ts b/src/index.ts index 8ccdcbf..2ccf3b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { CommanderError, type Command, program } from 'commander' import pkg from '../package.json' with { type: 'json' } import { configureCommandOutput } from './lib/command-output.js' import { BaseCliError } from './lib/errors.js' -import { getRequestedUserRef, isJsonMode, isNdjsonMode } from './lib/global-args.js' +import { getRequestedUserRef, isIdsOnlyMode, isJsonMode, isNdjsonMode } from './lib/global-args.js' import { preloadMarkdown } from './lib/markdown.js' import { formatError, formatErrorJson } from './lib/output.js' import { startEarlySpinner, stopEarlySpinner } from './lib/spinner.js' @@ -105,6 +105,7 @@ program ` Note for AI/LLM agents: Use --json or --ndjson flags for unambiguous, parseable output. + Use --ids-only on supported list commands when you only need entity IDs. Default JSON shows essential fields; use --full for all fields.`, ) @@ -195,7 +196,11 @@ if (process.argv[2] === 'completion-server') { ]) const wantsRaw = process.argv.slice(2).includes('--raw') const needsMarkdown = - !noMarkdownCommands.has(commandName) && !isJsonMode() && !isNdjsonMode() && !wantsRaw + !noMarkdownCommands.has(commandName) && + !isJsonMode() && + !isNdjsonMode() && + !isIdsOnlyMode() && + !wantsRaw startEarlySpinner() try { diff --git a/src/lib/auth-provider.test.ts b/src/lib/auth-provider.test.ts index a06f9b1..d2d9521 100644 --- a/src/lib/auth-provider.test.ts +++ b/src/lib/auth-provider.test.ts @@ -448,7 +448,7 @@ describe('createCommsTokenStore', () => { vi.unstubAllEnvs() }) - it('passes comms-cli wiring to cli-core: serviceName, no accountForUser override (uses cli-core default `user-${id}`), records location, and the parseRef-aware matcher', async () => { + it('passes comms-cli wiring and the fallback credential policy to cli-core', async () => { const createCommsTokenStore = await loadCreateCommsTokenStore() createCommsTokenStore() @@ -456,10 +456,22 @@ describe('createCommsTokenStore', () => { expect(options.serviceName).toBe('comms-cli') expect(options.accountForUser).toBeUndefined() expect(options.recordsLocation).toBe('/home/user/.config/comms-cli/config.json') + expect(options.credentialStore).toBe('fallback') const { matchCommsAccount: matcher } = await import('./auth-provider.js') expect(options.matchAccount).toBe(matcher) }) + it('passes an explicit credential policy through to cli-core', async () => { + const createCommsTokenStore = await loadCreateCommsTokenStore() + const credentialStore = () => 'system' as const + + createCommsTokenStore({ credentialStore }) + + expect(keyringMocks.createKeyringTokenStore.mock.calls[0][0].credentialStore).toBe( + credentialStore, + ) + }) + it('active() short-circuits to COMMS_API_TOKEN when no explicit ref is supplied', async () => { vi.stubEnv(TOKEN_ENV_VAR, 'env_token_value') const createCommsTokenStore = await loadCreateCommsTokenStore() diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts index f9417e9..44882b3 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -2,6 +2,7 @@ import { type AccountRef, type AuthAccount, type AuthProvider, + type CredentialStore, createDcrProvider, createKeyringTokenStore, type DcrRegisteredClient, @@ -96,6 +97,22 @@ export type CommsAccount = AuthAccount & { export type CommsTokenStore = KeyringTokenStore +export type CommsCredentialStore = CredentialStore +export const CREDENTIAL_STORES = ['fallback', 'system', 'plaintext'] as const + +export function parseCredentialStore(value: string): CommsCredentialStore { + switch (value) { + case 'system': + case 'plaintext': + case 'fallback': + return value + default: + throw new CliError('INVALID_CREDENTIAL_STORE', `Invalid credential store '${value}'.`, [ + 'Expected one of: fallback, system, plaintext.', + ]) + } +} + /** * Sentinel for the `{ id: '', label: '' }` snapshot that `tdc auth token` * persists when the user passes a raw token with no identity attached. The @@ -580,12 +597,17 @@ export async function findAccountInStore( * supplied — cli-core's `KeyringTokenStore` doesn't know about the env var, * and an explicit ref means the caller targets a specific stored account. */ -export function createCommsTokenStore(): CommsTokenStore { +export function createCommsTokenStore( + options: { + credentialStore?: CommsCredentialStore | (() => CommsCredentialStore) + } = {}, +): CommsTokenStore { const inner = createKeyringTokenStore({ serviceName: SECURE_STORE_SERVICE, userRecords: createCommsUserRecordStore(), recordsLocation: getConfigPath(), matchAccount: matchCommsAccount, + credentialStore: options.credentialStore ?? 'fallback', }) return Object.assign(Object.create(inner) as CommsTokenStore, { async active(ref?: AccountRef) { diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 7aaea7c..b18d37e 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -17,6 +17,7 @@ export type ErrorCode = | 'AUTH_TOKEN_EXCHANGE_FAILED' | 'FORBIDDEN' | 'INSUFFICIENT_SCOPE' + | 'INVALID_CREDENTIAL_STORE' | 'INVALID_TOKEN' | 'NO_TOKEN' | 'READ_ONLY' diff --git a/src/lib/global-args.test.ts b/src/lib/global-args.test.ts index da1e322..c321557 100644 --- a/src/lib/global-args.test.ts +++ b/src/lib/global-args.test.ts @@ -20,6 +20,10 @@ describe('parseGlobalArgs', () => { expect(parseGlobalArgs(['--ndjson']).ndjson).toBe(true) }) + it('parses --ids-only', () => { + expect(parseGlobalArgs(['--ids-only']).idsOnly).toBe(true) + }) + it('parses --no-spinner', () => { expect(parseGlobalArgs(['--no-spinner']).noSpinner).toBe(true) }) @@ -45,6 +49,7 @@ describe('parseGlobalArgs', () => { it('defaults all flags to false/undefined', () => { const result = parseGlobalArgs([]) expect(result).toEqual({ + idsOnly: false, json: false, ndjson: false, accessible: false, diff --git a/src/lib/global-args.ts b/src/lib/global-args.ts index 2867431..e871474 100644 --- a/src/lib/global-args.ts +++ b/src/lib/global-args.ts @@ -4,8 +4,8 @@ * Layers comms's `--include-private-channels`, `--non-interactive`, * `--interactive`, and the `--progress-jsonl ` space form on top of * the subset of cli-core's canonical shape that comms actually registers - * with Commander (`--json`, `--ndjson`, `--accessible`, `--no-spinner`, - * `--progress-jsonl[=path]`). + * with Commander (`--json`, `--ndjson`, `--ids-only`, `--accessible`, + * `--no-spinner`, `--progress-jsonl[=path]`). * * cli-core's parser also surfaces `quiet` and `verbose` from argv, but * comms does not register `--quiet` or `--verbose` globally (Commander @@ -39,7 +39,7 @@ type CommsSpecificFlags = { */ export type GlobalArgs = Pick< CoreGlobalArgs, - 'json' | 'ndjson' | 'accessible' | 'noSpinner' | 'user' + 'json' | 'ndjson' | 'idsOnly' | 'accessible' | 'noSpinner' | 'user' > & CommsSpecificFlags @@ -148,6 +148,10 @@ export function isNdjsonMode(): boolean { return store.get().ndjson } +export function isIdsOnlyMode(): boolean { + return store.get().idsOnly +} + /** Pre-subcommand `tdc --user ` (see `stripUserFlag` in `src/index.ts`). */ export function getRequestedUserRef(): string | undefined { return store.get().user diff --git a/src/lib/options.ts b/src/lib/options.ts index c3ac576..00ef6f5 100644 --- a/src/lib/options.ts +++ b/src/lib/options.ts @@ -1,6 +1,6 @@ -import type { ViewOptions as CoreViewOptions } from '@doist/cli-core' +import type { ListViewOptions as CoreListViewOptions } from '@doist/cli-core' -export type ViewOptions = CoreViewOptions & { +export type ViewOptions = CoreListViewOptions & { full?: boolean raw?: boolean } diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts index 424a8af..0149b4c 100644 --- a/src/lib/output.test.ts +++ b/src/lib/output.test.ts @@ -2,7 +2,7 @@ import { captureConsole } from '@doist/cli-core/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BaseCliError } from './errors.js' import { isAccessible, resetGlobalArgs } from './global-args.js' -import { formatError, formatErrorJson, printDryRun, printEmpty } from './output.js' +import { formatError, formatErrorJson, printDryRun } from './output.js' vi.mock('chalk') @@ -93,40 +93,6 @@ describe('printDryRun', () => { }) }) -describe('printEmpty', () => { - let logSpy: ReturnType - - beforeEach(() => { - logSpy = captureConsole('log') - }) - - it('prints "[]" for --json', () => { - printEmpty({ options: { json: true }, type: 'thread', message: 'No threads in inbox.' }) - expect(logSpy).toHaveBeenCalledTimes(1) - expect(logSpy).toHaveBeenCalledWith('[]') - }) - - it('does not call console.log at all for --ndjson (no stray newline)', () => { - printEmpty({ options: { ndjson: true }, type: 'thread', message: 'No threads in inbox.' }) - expect(logSpy).not.toHaveBeenCalled() - }) - - it('prints the human message when neither --json nor --ndjson is set', () => { - printEmpty({ options: {}, type: 'thread', message: 'No threads in inbox.' }) - expect(logSpy).toHaveBeenCalledWith('No threads in inbox.') - }) - - it('--json takes precedence over --ndjson when both are set', () => { - printEmpty({ - options: { json: true, ndjson: true }, - type: 'conversation', - message: 'unused', - }) - expect(logSpy).toHaveBeenCalledTimes(1) - expect(logSpy).toHaveBeenCalledWith('[]') - }) -}) - describe('formatError with BaseCliError', () => { it('formats a cli-core CliError instance (code, message, hints)', () => { const err = new BaseCliError('FILE_READ_ERROR', 'Could not read changelog file', { diff --git a/src/lib/output.ts b/src/lib/output.ts index d0b3287..966b515 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -239,25 +239,6 @@ export function printNdjson(items: T[], type?: EntityType, ful console.log(formatNdjson(items, type, full)) } -export function printEmpty({ - options, - type, - message, -}: { - options: { json?: boolean; ndjson?: boolean; full?: boolean } - type: EntityType - message: string -}): void { - if (options.json) { - console.log(formatJson([], type, options.full)) - return - } - if (options.ndjson) { - return - } - console.log(message) -} - export function pluralize(count: number, singular: string): string { return count === 1 ? singular : `${singular}s` } diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index a8f8012..ef49151 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -21,10 +21,13 @@ Access Comms messaging via the \`tdc\` CLI. Use when the user asks about their C 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 --credential-store system # Require the system credential manager +tdc auth login --credential-store plaintext # Explicitly allow plaintext config storage tdc auth login --callback-port # 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 tdc auth token # Save API token manually (prompts securely; scope unknown, assumed write-capable) +tdc auth token --credential-store system # Require the system credential manager tdc auth status # Verify authentication + show mode tdc auth status --json # Full status payload as JSON (--ndjson also supported) tdc auth status --user # Target a specific stored account (id, id:, or display name) @@ -40,6 +43,7 @@ tdc account [list|current|use |remove ] # Manage stored accounts; all # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"token-only"} tdc auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set tdc workspaces # List available workspaces +tdc workspaces --ids-only # Output only workspace IDs tdc workspace use # Set current workspace tdc completion install # Install shell completions tdc config view # Show the current CLI configuration file (token masked) @@ -50,7 +54,7 @@ tdc changelog # Show recent changelog entries tdc migrate urls # Translate old twist.com URLs to Comms URLs (needs a Twist 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 (all \`tdc groups\` writes require it). 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 (all \`tdc groups\` writes require it). Credential storage defaults to \`fallback\`: use the system credential manager when available, otherwise warn and save to \`~/.config/comms-cli/config.json\`. Pass \`--credential-store system\` to fail instead of writing plaintext, or \`--credential-store plaintext\` to choose config-file storage explicitly. \`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. @@ -80,6 +84,7 @@ tdc inbox --archive-filter archived # Show only done threads tdc inbox --channel # Filter by channel name (fuzzy) tdc inbox --since # Filter by date (ISO format) tdc inbox --limit # Max items (default: 50) +tdc inbox --ids-only # Output only thread IDs, one per line \`\`\` ## Threads @@ -169,6 +174,8 @@ tdc conversation list --name "release" # Filter by title substring (case-ins tdc conversation list --state archived # Archived conversations only (active|all|archived; default active) tdc conversation list --snippet # Include the latest message snippet tdc conversation list --limit 20 --json # Cap rows and output as JSON +tdc conversation list --ids-only # Output only conversation IDs +tdc conversation unread --ids-only # Output only unread conversation IDs tdc conversation # View conversation (shorthand for view) tdc conversation view # View conversation messages tdc conversation with # Find your 1:1 DM with a user @@ -236,10 +243,12 @@ tdc user --json --full # Include all fields in JSON output tdc users # List active workspace users tdc users --search # Filter by name/email tdc users --include-removed # Include users removed from the workspace +tdc users --ids-only # Output only user IDs tdc channels # List active joined workspace channels (alias of: tdc channel list) tdc channels --state all # Include archived joined channels too tdc channels --scope discoverable # Active public channels you can see but have not joined tdc channels --scope public --state all --json # All visible public channels, with joined status +tdc channels --ids-only # Output only channel IDs tdc channel create "Engineering" # Create a channel in the current workspace tdc channel create "Leadership Team" --private --users id:10,id:20 # Create private channel with initial members tdc channel create "Product" --workspace "Doist" --description "Product discussions" --json # Create and return channel as JSON @@ -260,8 +269,10 @@ tdc channel threads --since 2026-01-01 # Filter by last-updated date (ISO) tdc channel threads --limit 20 # Max threads per page (default: 50) tdc channel threads --limit 20 --cursor # Paginate tdc channel threads --json # { results, nextCursor } with isUnread + url +tdc channel threads --ids-only # Output only thread IDs; pagination notice goes to stderr tdc channel members # List a channel's members + groups fully in the channel tdc channel members --json # JSON with id, name, workspaceId, members +tdc channel members --ids-only # Output only member user IDs tdc channel members add alice group:Design # Add users and/or expand group: members tdc channel members add a@d.com id:789 --json # Add refs, output result as JSON tdc channel members remove alice group:Frontend # Remove users and/or group members @@ -270,6 +281,7 @@ tdc channel members set alice bob # Dry-run by default; refuses to remove tdc groups # List workspace groups tdc groups --search "frontend" # Filter groups by name (case-insensitive) tdc groups --json # JSON output +tdc groups --ids-only # Output only group IDs tdc groups --json --full # Include all fields in JSON output tdc groups view # Show group with member details tdc groups view --json # JSON output with id, name, workspaceId, members @@ -394,11 +406,12 @@ Output is one line per URL in input order: \`old -> new\` on success, \`old ✗ ## Output Formats -All list/view commands support: +List and view commands commonly support \`--json\`, \`--ndjson\`, and \`--full\`. List commands with one clear entity ID also support \`--ids-only\`; check command help for the exact surface. \`\`\`bash --json # Output as JSON --ndjson # Output as newline-delimited JSON (for streaming) +--ids-only # Output one stable ID per line; mutually exclusive with JSON/NDJSON --full # Include all fields (default shows essential fields only) \`\`\`