From 48ee613ad83ba6ab63666933d8d05f19c2a68e5b Mon Sep 17 00:00:00 2001 From: Amir Date: Wed, 20 May 2026 16:58:48 +0200 Subject: [PATCH 1/5] chore: drop Twist branding and legacy migration code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comms is a hard fork of Twist with no upgrade path from twist-cli, so the v1→v2 auth migration shim, the `updateChannel` dual-write back-compat, and every "Twist" / `tw` reference in source, docs, workflows, and skills are dead weight. Changes: - Rebrand: `Twist` → `Comms`, `tw` → `cm`, `twist.com` → `comms.todoist.com`, `twist-mention://` → `comms-mention://`, `TWIST_INCLUDE_PRIVATE_CHANNELS` → `COMMS_INCLUDE_PRIVATE_CHANNELS`, `TW_ACCESSIBLE`/`TW_SPINNER` → `CM_ACCESSIBLE`/`CM_SPINNER`, CSS `--twist-teal*` → `--comms-teal*`. - Identifiers: `createTwistAuthProvider` → `createCommsAuthProvider`, `attachTwist*Command` → `attachComms*Command`, `ParsedTwistUrl`, `TwistUrlRoute`, `TwistHandshake`, `looksLikeTwistAppUrl`, mocks, test helpers, etc. - File renames: `skills/twist-cli/` → `skills/comms-cli/`, `docs/twist-search.md` → `docs/comms-search.md`, `icons/twist-cli.{png,svg}` → `icons/comms-cli.{png,svg}`, `.github/workflows/update-twist-sdk.yml` → `update-comms-sdk.yml`. - Drop legacy migration: delete `src/lib/migrate-auth.{ts,test.ts}`, remove `isLegacyAuthActive` / `assertV2Available` / `readLegacyTokenSnapshot` / `dischargeLegacyState` from `auth-provider.ts`, drop the `LEGACY_KEYRING_ACCOUNT` constant, drop `AUTH_MIGRATION_PENDING` error code, simplify `postinstall.ts` to just update installed skills. - Drop legacy config fields: `token`, `pendingSecureStoreClear`, `authMode`, `authScope`, `authUserId`, `authUserName` at config root, plus `config_version` and the camelCase `updateChannel` on-disk alias. - Drop `migrateLegacyChannelKey` preAction in `commands/update/index.ts`. - Remove the Twist post-action release announcement step in `.github/workflows/release.yml` (no Comms equivalent yet). Pre-existing type/test failures noted in the bootstrap commit (UUID strings vs numbers, `name` → `fullName`, `awayMode` removal, etc.) are out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) --- .agents/skills/add-command/SKILL.md | 16 +- .claude/skills/add-command/SKILL.md | 2 +- .github/workflows/release.yml | 59 --- ...ate-twist-sdk.yml => update-comms-sdk.yml} | 18 +- AGENTS.md | 20 +- CONTRIBUTING.md | 14 +- README.md | 120 ++--- docs/SPEC.md | 148 +++--- docs/{twist-search.md => comms-search.md} | 6 +- icons/{twist-cli.png => comms-cli.png} | Bin icons/{twist-cli.svg => comms-cli.svg} | 0 release.config.js | 2 +- skills/comms-cli/SKILL.md | 402 +++++++++++++++ skills/twist-cli/SKILL.md | 402 --------------- src/commands/account/account.test.ts | 98 +--- src/commands/account/current.ts | 15 +- src/commands/account/helpers.ts | 22 - src/commands/account/index.ts | 6 +- src/commands/account/list.ts | 4 +- src/commands/account/remove.ts | 2 - src/commands/account/use.ts | 2 - src/commands/auth/auth.test.ts | 80 +-- src/commands/auth/index.ts | 16 +- src/commands/auth/login.ts | 6 +- src/commands/auth/logout.ts | 4 +- src/commands/auth/status.ts | 8 +- src/commands/auth/store-wrap.ts | 4 +- src/commands/auth/token.ts | 4 +- src/commands/away/away.test.ts | 18 +- src/commands/away/index.ts | 14 +- src/commands/changelog.test.ts | 16 +- src/commands/channel/channel.test.ts | 32 +- src/commands/channel/index.ts | 28 +- src/commands/channel/threads.test.ts | 50 +- src/commands/comment/comment.test.ts | 32 +- src/commands/comment/index.ts | 14 +- src/commands/completion/helpers.ts | 8 +- src/commands/completion/install.ts | 4 +- src/commands/completion/uninstall.ts | 4 +- src/commands/config/config.test.ts | 88 ++-- src/commands/config/index.ts | 12 +- src/commands/config/view.ts | 17 +- .../conversation/conversation.test.ts | 56 +-- src/commands/conversation/index.ts | 32 +- src/commands/doctor.test.ts | 80 +-- src/commands/doctor.ts | 4 +- src/commands/groups/groups.test.ts | 74 +-- src/commands/groups/index.ts | 36 +- src/commands/inbox.test.ts | 16 +- src/commands/inbox.ts | 12 +- src/commands/mentions.test.ts | 10 +- src/commands/mentions.ts | 6 +- src/commands/msg/index.ts | 14 +- src/commands/msg/msg.test.ts | 12 +- src/commands/react.test.ts | 10 +- src/commands/react.ts | 14 +- src/commands/search.test.ts | 6 +- src/commands/search.ts | 8 +- src/commands/skill/install.ts | 2 +- src/commands/skill/skill.test.ts | 22 +- src/commands/skill/uninstall.ts | 2 +- src/commands/skill/update.ts | 2 +- src/commands/thread/index.ts | 44 +- src/commands/thread/thread.test.ts | 124 ++--- src/commands/update/index.ts | 39 +- src/commands/update/update.test.ts | 36 +- src/commands/user.test.ts | 10 +- src/commands/user.ts | 8 +- src/commands/view.test.ts | 32 +- src/commands/view.ts | 32 +- src/commands/workspace.ts | 8 +- src/index.ts | 4 +- src/lib/api.ts | 2 +- src/lib/auth-constants.ts | 7 - src/lib/auth-pages.ts | 34 +- src/lib/auth-provider.test.ts | 184 +------ src/lib/auth-provider.ts | 150 +----- src/lib/auth.test.ts | 25 - src/lib/auth.ts | 21 +- src/lib/completion.test.ts | 6 +- src/lib/completion.ts | 2 +- src/lib/config.test.ts | 73 +-- src/lib/config.ts | 112 +---- src/lib/errors.ts | 1 - src/lib/global-args.test.ts | 74 +-- src/lib/global-args.ts | 55 +-- src/lib/input.test.ts | 4 +- src/lib/markdown.test.ts | 4 +- src/lib/markdown.ts | 2 +- src/lib/migrate-auth.test.ts | 145 ------ src/lib/migrate-auth.ts | 65 --- src/lib/output.test.ts | 16 +- src/lib/permissions.ts | 4 +- src/lib/progress.test.ts | 42 +- src/lib/public-channels.test.ts | 52 +- src/lib/refs.test.ts | 7 +- src/lib/refs.ts | 42 +- src/lib/skills/content.ts | 464 +++++++++--------- src/lib/skills/create-installer.ts | 4 +- src/lib/update.ts | 4 +- src/lib/user-records.ts | 2 +- src/postinstall.test.ts | 16 - src/postinstall.ts | 5 +- 103 files changed, 1636 insertions(+), 2560 deletions(-) rename .github/workflows/{update-twist-sdk.yml => update-comms-sdk.yml} (83%) rename docs/{twist-search.md => comms-search.md} (95%) rename icons/{twist-cli.png => comms-cli.png} (100%) rename icons/{twist-cli.svg => comms-cli.svg} (100%) create mode 100644 skills/comms-cli/SKILL.md delete mode 100644 skills/twist-cli/SKILL.md delete mode 100644 src/commands/account/helpers.ts delete mode 100644 src/lib/migrate-auth.test.ts delete mode 100644 src/lib/migrate-auth.ts diff --git a/.agents/skills/add-command/SKILL.md b/.agents/skills/add-command/SKILL.md index bd46cab..4c485a9 100644 --- a/.agents/skills/add-command/SKILL.md +++ b/.agents/skills/add-command/SKILL.md @@ -1,6 +1,6 @@ --- name: add-command -description: Guide for adding new CLI commands or subcommands to twist-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality. +description: Guide for adding new CLI commands or subcommands to comms-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality. --- # Adding a New CLI Command or Subcommand @@ -19,7 +19,7 @@ Color convention: ## 2. 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 (`tw auth login --read-only`). +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 (`cm auth login --read-only`). - **Read-only methods** (fetch/list/view): add to `KNOWN_SAFE_API_METHODS` - **Mutating methods** (create/update/delete/archive/mute): do NOT add — they are blocked by default, which is the correct behavior @@ -60,7 +60,7 @@ Single-subcommand commands (e.g., `channel.ts`, `inbox.ts`) remain as flat files ### ID resolution -- `resolveThreadId(ref)` — resolve thread by numeric ID or Twist URL +- `resolveThreadId(ref)` — resolve thread by numeric ID or Comms URL - `resolveChannelId(ref)` — resolve channel by numeric ID, URL, or fuzzy name - `resolveWorkspaceRef(ref)` — resolve workspace by ID or fuzzy name - `resolveConversationId(ref)` — resolve conversation by numeric ID or URL @@ -89,7 +89,7 @@ The variable assignment (`const myCmd = ...`) is needed so the `.action()` callb ### Implicit view subcommand -For entity commands with a `view` subcommand, mark it as the default so `tw thread 123` maps to `tw thread view 123`: +For entity commands with a `view` subcommand, mark it as the default so `cm thread 123` maps to `cm thread view 123`: ```typescript thread @@ -128,7 +128,7 @@ const commands: Record Promise<(p: Command) => void>]> = ## 4. Accessibility (`src/lib/output.ts`) -The CLI supports accessible mode via `isAccessible()` (checks `TW_ACCESSIBLE=1` or `--accessible` flag). When adding output that uses color or visual elements, consider whether information is conveyed **only** by color or decoration. +The CLI supports accessible mode via `isAccessible()` (checks `CM_ACCESSIBLE=1` or `--accessible` flag). When adding output that uses color or visual elements, consider whether information is conveyed **only** by color or decoration. ### When to add accessible alternatives @@ -160,12 +160,12 @@ Tests mock the API layer directly using `vi.mock` and `vi.hoisted`. Follow the e ```typescript const apiMocks = vi.hoisted(() => ({ - getTwistClient: vi.fn(), + getCommsClient: vi.fn(), })) vi.mock('../lib/api.js', async (importOriginal) => ({ ...(await importOriginal()), - getTwistClient: apiMocks.getTwistClient, + getCommsClient: apiMocks.getCommsClient, })) vi.mock('../lib/markdown.js', () => ({ @@ -218,7 +218,7 @@ After all code changes are complete: npm run build && npm run sync:skill ``` -This builds the project and regenerates `skills/twist-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. +This builds the project and regenerates `skills/comms-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 diff --git a/.claude/skills/add-command/SKILL.md b/.claude/skills/add-command/SKILL.md index cb5b9a7..a2077b3 100644 --- a/.claude/skills/add-command/SKILL.md +++ b/.claude/skills/add-command/SKILL.md @@ -1,6 +1,6 @@ --- name: add-command -description: Guide for adding new CLI commands or subcommands to twist-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality. +description: Guide for adding new CLI commands or subcommands to comms-cli. Use when implementing new SDK endpoints, adding subcommands to existing command groups, or extending CLI functionality. --- See [/.agents/skills/add-command/SKILL.md](../../../.agents/skills/add-command/SKILL.md) for the full guide. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 190778c..0b56694 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,11 +68,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Capture previous tag - if: github.ref_name == 'main' - id: previous_tag - run: echo "tag=$(git describe --tags --abbrev=0 --exclude='*-*' 2>/dev/null || true)" >> "$GITHUB_OUTPUT" - - name: Release run: npx semantic-release env: @@ -81,57 +76,3 @@ jobs: GIT_AUTHOR_EMAIL: ${{ steps.bot_user.outputs.id }}+${{ steps.generate_token.outputs.app-slug }}[bot]@users.noreply.github.com GIT_COMMITTER_NAME: ${{ steps.generate_token.outputs.app-slug }}[bot] GIT_COMMITTER_EMAIL: ${{ steps.bot_user.outputs.id }}+${{ steps.generate_token.outputs.app-slug }}[bot]@users.noreply.github.com - - - name: Derive release announcement - if: github.ref_name == 'main' - id: announcement - env: - PREVIOUS_TAG: ${{ steps.previous_tag.outputs.tag }} - run: | - git fetch --force --tags origin - - new_tag="$(git describe --tags --abbrev=0 2>/dev/null || true)" - if [ -z "${new_tag}" ] || [ "${new_tag}" = "${PREVIOUS_TAG}" ]; then - echo "should_announce=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - package_name="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).name")" - package_version="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")" - release_version="${new_tag#v}" - - package_url="https://www.npmjs.com/package/${package_name}/v/${package_version}" - release_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${new_tag}" - repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" - - if [ -n "${PREVIOUS_TAG}" ]; then - changelog="$(git log --no-merges --reverse --pretty='format:- %s (%H-%h)' "${PREVIOUS_TAG}..${new_tag}" | grep -v '^- chore(release): ' || true)" - else - changelog="$(git log --no-merges --reverse --pretty='format:- %s (%H-%h)' "${new_tag}" | grep -v '^- chore(release): ' || true)" - fi - - if [ -z "${changelog}" ]; then - changelog='- No additional commits listed.' - else - changelog="$(printf '%s\n' "${changelog}" | sed -E -e 's,\(([a-f0-9]+)-([a-f0-9]+)\),([`\2`]('"${repo_url}"'/commit/\1)),g' | sed -E -e 's,\(#([0-9]+)\),([#\1]('"${repo_url}"'/pull/\1)),g')" - fi - - { - echo "should_announce=true" - echo "message<> "$GITHUB_OUTPUT" - - - name: Announce release in Twist - if: github.ref_name == 'main' && steps.announcement.outputs.should_announce == 'true' - uses: Doist/twist-post-action@74a0255b75ad93c06b9eb1009960106efe13f5ca - with: - message: ${{ steps.announcement.outputs.message }} - install_id: ${{ secrets.TWIST_RELEASE_INSTALL_ID }} - install_token: ${{ secrets.TWIST_RELEASE_INSTALL_TOKEN }} - continue-on-error: true diff --git a/.github/workflows/update-twist-sdk.yml b/.github/workflows/update-comms-sdk.yml similarity index 83% rename from .github/workflows/update-twist-sdk.yml rename to .github/workflows/update-comms-sdk.yml index a481805..cbaa7f5 100644 --- a/.github/workflows/update-twist-sdk.yml +++ b/.github/workflows/update-comms-sdk.yml @@ -1,4 +1,4 @@ -name: Update Twist SDK +name: Update Comms SDK on: workflow_dispatch: @@ -8,7 +8,7 @@ permissions: pull-requests: write jobs: - update-twist-sdk: + update-comms-sdk: runs-on: ubuntu-latest timeout-minutes: 10 @@ -27,8 +27,8 @@ jobs: - name: Install current dependencies run: npm ci - - name: Update @doist/twist-sdk to latest - run: npm install @doist/twist-sdk@latest + - name: Update @doist/comms-sdk to latest + run: npm install @doist/comms-sdk@latest - name: Build run: npm run build @@ -46,10 +46,10 @@ jobs: id: changes run: | if git diff --quiet package.json package-lock.json; then - echo "No updates available - @doist/twist-sdk is already at the latest version" + echo "No updates available - @doist/comms-sdk is already at the latest version" echo "has_changes=false" >> $GITHUB_OUTPUT else - echo "Changes detected - @doist/twist-sdk has been updated" + echo "Changes detected - @doist/comms-sdk has been updated" echo "has_changes=true" >> $GITHUB_OUTPUT fi @@ -59,7 +59,7 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add package.json package-lock.json - git commit -m "fix(deps): update to latest \`@doist/twist-sdk\` package" + git commit -m "fix(deps): update to latest \`@doist/comms-sdk\` package" git push - name: Trigger release workflow @@ -74,9 +74,9 @@ jobs: - name: Report success run: | if [ "${{ steps.changes.outputs.has_changes }}" == "true" ]; then - echo "✅ Successfully updated @doist/twist-sdk package" + echo "✅ Successfully updated @doist/comms-sdk package" echo "📦 Changes committed to main branch" echo "🚀 Release workflow triggered - semantic release will create a new patch version" else - echo "ℹ️ No updates needed - @doist/twist-sdk is already at the latest version" + echo "ℹ️ No updates needed - @doist/comms-sdk is already at the latest version" fi diff --git a/AGENTS.md b/AGENTS.md index 30628dc..8395853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ node dist/index.js ## Architecture -This is a TypeScript CLI (`tw`) for Twist messaging, built with Commander.js. +This is a TypeScript CLI (`cm`) for Comms messaging, built with Commander.js. **Entry point**: `src/index.ts` registers all commands with Commander. @@ -44,19 +44,19 @@ This is a TypeScript CLI (`tw`) for Twist messaging, built with Commander.js. **Lib** (`src/lib/`): -- `api.ts` - Singleton TwistApi client from `@doist/twist-sdk`, workspace/user caching -- `refs.ts` - Reference parsing: accepts IDs (`id:123` or bare `123`), Twist URLs, or fuzzy names for workspaces/users +- `api.ts` - Singleton CommsApi client from `@doist/comms-sdk`, workspace/user caching +- `refs.ts` - Reference parsing: accepts IDs (`id:123` or bare `123`), Comms URLs, or fuzzy names for workspaces/users - `output.ts` - JSON/NDJSON formatting with essential field filtering per entity type -- `config.ts` - Persists config to `~/.config/twist-cli/config.json` +- `config.ts` - Persists config to `~/.config/comms-cli/config.json` - `auth.ts` - Token loading/saving/clearing (env var or config file) - `markdown.ts` - Terminal markdown rendering via `marked` + `marked-terminal` - `completion.ts` - Commander tree-walker + completion helpers for shell tab completion -**Reference system**: The CLI accepts flexible references throughout - numeric IDs, `id:` prefixed IDs, full Twist URLs (parsed via `parseTwistUrl`), or fuzzy name matching for workspaces/users. +**Reference system**: The CLI accepts flexible references throughout - numeric IDs, `id:` prefixed IDs, full Comms URLs (parsed via `parseCommsUrl`), or fuzzy name matching for workspaces/users. ## Key Patterns -- **Implicit view subcommand**: `tw thread ` defaults to `tw thread view ` via Commander's `{ isDefault: true }`. Same for `conversation` and `msg`. Edge case: if a ref matches a subcommand name (e.g., "reply"), the subcommand wins — user must use `tw thread view reply` +- **Implicit view subcommand**: `cm thread ` defaults to `cm thread view ` via Commander's `{ isDefault: true }`. Same for `conversation` and `msg`. Edge case: if a ref matches a subcommand name (e.g., "reply"), the subcommand wins — user must use `cm thread view reply` - **Named flag aliases**: Where commands accept positional `[workspace-ref]`, the `--workspace` flag is also accepted. Error if both positional and flag are provided - **JSON output on mutating commands**: Mutating commands (create, update, delete, archive) should support `--json` output where it provides scripting value. Commands that return an object from the API (create/update) should also support `--full`. Commands where the API returns void should output a minimal status object (e.g. `{ id, deleted: true }` or `{ id, isArchived: true }`). Extend `MutationOptions` in `src/lib/options.ts` (which already includes `json` and `full`) rather than adding these fields ad hoc. Use `formatJson()` from `src/lib/output.ts` for the output. See `src/commands/away.ts` as the reference implementation. - **Spinner messages**: When adding new SDK method calls, add a corresponding entry in the `API_SPINNER_MESSAGES` map in `src/lib/api.ts`. Every user-facing API call should have a spinner message so the CLI shows progress feedback. @@ -87,7 +87,7 @@ Lefthook runs type-check, oxlint, and oxfmt on pre-commit, tests on pre-push. ## Skill Content (Agent Command Reference) -The file `src/lib/skills/content.ts` exports `SKILL_CONTENT` — a comprehensive command reference that gets installed into AI agent skill directories via `tw skill install`. This is the source of truth that agents use to understand available CLI commands. +The file `src/lib/skills/content.ts` exports `SKILL_CONTENT` — a comprehensive command reference that gets installed into AI agent skill directories via `cm skill install`. This is the source of truth that agents use to understand available CLI commands. **Whenever commands, subcommands, flags, or options are added, updated, or removed in `src/commands/`, the `SKILL_CONTENT` in `src/lib/skills/content.ts` must be updated to match.** This includes: @@ -98,7 +98,7 @@ The file `src/lib/skills/content.ts` exports `SKILL_CONTENT` — a comprehensive After updating `SKILL_CONTENT`: -1. Run `npm run build && npm run sync:skill` to regenerate `skills/twist-cli/SKILL.md` (the standalone skill file used by `npx skills add`) -2. Run `tw skill update claude-code` (and any other installed agents) to propagate changes to installed skill files +1. Run `npm run build && npm run sync:skill` to regenerate `skills/comms-cli/SKILL.md` (the standalone skill file used by `npx skills add`) +2. Run `cm skill update claude-code` (and any other installed agents) to propagate changes to installed skill files -A CI check (`npm run check:skill-sync`) runs on pull requests and will fail if `skills/twist-cli/SKILL.md` is out of sync with `content.ts`. +A CI check (`npm run check:skill-sync`) runs on pull requests and will fail if `skills/comms-cli/SKILL.md` is out of sync with `content.ts`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b2c297..2118afd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,20 +1,20 @@ -# Contributing to Twist CLI +# Contributing to Comms CLI -The following is a set of guidelines for contributing to Twist CLI. Please read these guidelines before creating an issue or pull request. +The following is a set of guidelines for contributing to Comms CLI. Please read these guidelines before creating an issue or pull request. ## Open Development -All work on Twist CLI happens directly on [GitHub](https://github.com/Doist/twist-cli). Both core team members and external contributors send pull requests that go through the same review process. +All work on Comms CLI happens directly on [GitHub](https://github.com/Doist/comms-cli). Both core team members and external contributors send pull requests that go through the same review process. ## Semantic Versioning -Twist CLI follows [semantic versioning](https://semver.org/). We release patch versions for bugfixes, minor versions for new features or non-essential changes, and major versions for any breaking changes. +Comms CLI follows [semantic versioning](https://semver.org/). We release patch versions for bugfixes, minor versions for new features or non-essential changes, and major versions for any breaking changes. Every significant change is documented in the [CHANGELOG.md](CHANGELOG.md) file. ## Branch Organization -Submit all changes to the [main](https://github.com/Doist/twist-cli/tree/main) branch (via PR) by default. For pre-release work, target the `next` branch instead — see [Release Process](#release-process-core-team-only) for details. +Submit all changes to the [main](https://github.com/Doist/comms-cli/tree/main) branch (via PR) by default. For pre-release work, target the `next` branch instead — see [Release Process](#release-process-core-team-only) for details. We do our best to keep `main` in good shape, with all tests passing. @@ -91,9 +91,9 @@ To test features before publishing a stable release: ### Installing a pre-release ```sh -npm install @doist/twist-cli@next +npm install @doist/comms-cli@next ``` ## License -By contributing to Twist CLI, you agree that your contributions will be licensed under its [MIT license](LICENSE). +By contributing to Comms CLI, you agree that your contributions will be licensed under its [MIT license](LICENSE). diff --git a/README.md b/README.md index de4496a..869c30e 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@

- Twist CLI + Comms CLI

-# Twist CLI +# Comms CLI -A command-line interface for Twist. +A command-line interface for Comms. ## Installation > ```bash -> npm install -g @doist/twist-cli +> npm install -g @doist/comms-cli > ``` ### Agent Skills @@ -17,19 +17,19 @@ A command-line interface for Twist. Install skills for your coding agent: ```bash -tw skill install claude-code -tw skill install codex -tw skill install cursor -tw skill install gemini -tw skill install pi -tw skill install universal +cm skill install claude-code +cm skill install codex +cm skill install cursor +cm skill install gemini +cm skill install pi +cm skill install universal ``` -Skills are installed to `~//skills/twist-cli/SKILL.md` (e.g. `~/.claude/` for claude-code, `~/.agents/` for universal, etc.). When updating the CLI, installed skills are updated automatically. The `universal` agent is compatible with Amp, OpenCode, and other agents that read from `~/.agents/`. +Skills are installed to `~//skills/comms-cli/SKILL.md` (e.g. `~/.claude/` for claude-code, `~/.agents/` for universal, etc.). When updating the CLI, installed skills are updated automatically. The `universal` agent is compatible with Amp, OpenCode, and other agents that read from `~/.agents/`. ```bash -tw skill list -tw skill uninstall +cm skill list +cm skill uninstall ``` ## Uninstallation @@ -37,112 +37,112 @@ tw skill uninstall First, remove any installed agent skills: ```bash -tw skill uninstall +cm skill uninstall ``` Then uninstall the CLI: ```bash -npm uninstall -g @doist/twist-cli +npm uninstall -g @doist/comms-cli ``` ## Local Setup ```bash -git clone https://github.com/Doist/twist-cli.git -cd twist-cli +git clone https://github.com/Doist/comms-cli.git +cd comms-cli npm install npm run build npm link ``` -This makes the `tw` command available globally. +This makes the `cm` command available globally. ## Setup ```bash -tw auth login +cm auth login ``` -This opens your browser to authenticate with Twist. Once approved, the token is stored in your OS credential manager: +This opens your browser to authenticate with Comms. Once approved, the token is stored 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/twist-cli/config.json`. Existing plaintext tokens are migrated automatically the next time the CLI reads them successfully from the config file. 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`. Non-secret settings such as the current workspace remain in the config file. ### Alternative methods **Manual token:** ```bash -tw auth token "your-token" +cm auth token "your-token" ``` **Environment variable:** ```bash -export TWIST_API_TOKEN="your-token" +export COMMS_API_TOKEN="your-token" ``` -`TWIST_API_TOKEN` always takes priority over the stored token. +`COMMS_API_TOKEN` always takes priority over the stored token. ### Auth commands ```bash -tw auth status # check if authenticated -tw auth logout # remove saved token +cm auth status # check if authenticated +cm auth logout # remove saved token ``` ## Usage ```bash -tw inbox # inbox threads -tw inbox --unread # unread threads only -tw mentions # content mentioning you -tw mentions --since 2026-04-01 --all --json -tw thread view # view thread with comments -tw thread view --comment 123 # view a specific comment -tw thread reply # reply to a thread -tw thread rename "New title" # rename a thread -tw thread update "New body" # edit a thread's body (first post) -tw conversation unread # list unread conversations -tw conversation view # view conversation messages -tw msg view # view a conversation message -tw search "keyword" # search across workspace -tw search "keyword" --all # fetch all result pages -tw react thread 👍 # add reaction -tw away # show away status -tw away set vacation 2026-03-20 # set away until date -tw away clear # clear away status -tw groups # list groups in a workspace -tw groups view # show a group with members -tw groups create "Frontend" # create a group -tw groups create "FE" --users alice@doist.com,bob@doist.com -tw groups rename "New name" # rename a group -tw groups delete --yes # delete a group -tw groups add-user alice@doist.com bob@doist.com -tw groups remove-user id:123,id:456 +cm inbox # inbox threads +cm inbox --unread # unread threads only +cm mentions # content mentioning you +cm mentions --since 2026-04-01 --all --json +cm thread view # view thread with comments +cm thread view --comment 123 # view a specific comment +cm thread reply # reply to a thread +cm thread rename "New title" # rename a thread +cm thread update "New body" # edit a thread's body (first post) +cm conversation unread # list unread conversations +cm conversation view # view conversation messages +cm msg view # view a conversation message +cm search "keyword" # search across workspace +cm search "keyword" --all # fetch all result pages +cm react thread 👍 # add reaction +cm away # show away status +cm away set vacation 2026-03-20 # set away until date +cm away clear # clear away status +cm groups # list groups in a workspace +cm groups view # show a group with members +cm groups create "Frontend" # create a group +cm groups create "FE" --users alice@doist.com,bob@doist.com +cm groups rename "New name" # rename a group +cm groups delete --yes # delete a group +cm groups add-user alice@doist.com bob@doist.com +cm groups remove-user id:123,id:456 ``` -References accept IDs (`123` or `id:123`), Twist URLs, or fuzzy names (for workspaces/users). +References accept IDs (`123` or `id:123`), Comms URLs, or fuzzy names (for workspaces/users). -Run `tw --help` or `tw --help` for more options. +Run `cm --help` or `cm --help` for more options. ## Shell Completions Tab completion is available for bash, zsh, and fish: ```bash -tw completion install # prompts for shell -tw completion install bash # or: zsh, fish +cm completion install # prompts for shell +cm completion install bash # or: zsh, fish ``` Restart your shell or source your config file to activate. To remove: ```bash -tw completion uninstall +cm completion uninstall ``` ## Machine-readable output @@ -150,9 +150,9 @@ tw completion uninstall All list/view commands support `--json` and `--ndjson` flags for scripting: ```bash -tw inbox --json # JSON array -tw inbox --ndjson # newline-delimited JSON -tw inbox --json --full # include all fields +cm inbox --json # JSON array +cm inbox --ndjson # newline-delimited JSON +cm inbox --json --full # include all fields ``` ## Development diff --git a/docs/SPEC.md b/docs/SPEC.md index c8ea4a3..15db1bd 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1,6 +1,6 @@ -# twist-cli Specification +# comms-cli Specification -A command-line interface for Twist, following the architecture and patterns established by `todoist-cli`. +A command-line interface for Comms, following the architecture and patterns established by `todoist-cli`. ## Tech Stack @@ -8,7 +8,7 @@ A command-line interface for Twist, following the architecture and patterns esta - **Language**: TypeScript 5.x (strict mode) - **CLI Framework**: Commander.js - **Terminal Styling**: chalk -- **API Client**: `@doist/twist-sdk` +- **API Client**: `@doist/comms-sdk` - **Testing**: vitest - **Formatting**: prettier - **Git Hooks**: lefthook @@ -42,16 +42,16 @@ __tests__/ # Test suite ## Package & Binary -- **Package name**: `@doist/twist-cli` -- **Binary**: `tw` +- **Package name**: `@doist/comms-cli` +- **Binary**: `cm` ## Authentication Token resolution (priority order): -1. Environment variable: `TWIST_API_TOKEN` +1. Environment variable: `COMMS_API_TOKEN` 2. System credential manager (Keychain, Credential Manager, or Secret Service) -3. Legacy plaintext token in `~/.config/twist-cli/config.json` during auto-migration +3. Legacy plaintext token in `~/.config/comms-cli/config.json` during auto-migration 4. Plaintext config fallback when the OS credential store is unavailable ## Workspace Scoping @@ -59,7 +59,7 @@ Token resolution (priority order): Commands that require a workspace context use this resolution order: 1. `--workspace ` flag (if provided) -2. Config-stored current workspace (`tw workspace use `) +2. Config-stored current workspace (`cm workspace use `) 3. User's default workspace from API (auto-stored to config on first use) --- @@ -68,7 +68,7 @@ Commands that require a workspace context use this resolution order: ### Workspace Commands -#### `tw workspaces` +#### `cm workspaces` List all workspaces the user belongs to. @@ -76,7 +76,7 @@ Options: - `--json` / `--ndjson` - Machine-readable output -#### `tw workspace use ` +#### `cm workspace use ` Set the current workspace for subsequent commands. @@ -88,11 +88,11 @@ Arguments: ### User Commands -#### `tw user` +#### `cm user` Display current user info (name, email, timezone, default workspace). -#### `tw users [workspace-ref]` +#### `cm users [workspace-ref]` List users in a workspace. @@ -109,7 +109,7 @@ Options: ### Channel Commands -#### `tw channels [workspace-ref]` +#### `cm channels [workspace-ref]` List channels in a workspace. @@ -125,9 +125,9 @@ Options: ### Inbox Commands -#### `tw inbox [workspace-ref]` +#### `cm inbox [workspace-ref]` -Show inbox threads (mirrors Twist UI inbox - threads only, not DMs). +Show inbox threads (mirrors Comms UI inbox - threads only, not DMs). Arguments: @@ -151,13 +151,13 @@ Output format (human-readable): ### Thread Commands -#### `tw thread view ` +#### `cm thread view ` Display a thread with its comments. Arguments: -- `thread-ref` - Thread ID or Twist URL +- `thread-ref` - Thread ID or Comms URL Options: @@ -172,18 +172,18 @@ Output: - Full thread content with markdown rendered (unless `--raw`) - Comments with full content (detail view = no truncation) -#### `tw thread reply [content]` +#### `cm thread reply [content]` Post a comment to a thread. Arguments: -- `thread-ref` - Thread ID or Twist URL +- `thread-ref` - Thread ID or Comms URL - `content` - Comment content (optional if using stdin or editor) Content input priority: -1. Stdin (if piped: `echo "text" | tw thread reply id:123`) +1. Stdin (if piped: `echo "text" | cm thread reply id:123`) 2. Argument (if provided) 3. Opens `$EDITOR` (if neither stdin nor argument) @@ -195,13 +195,13 @@ Output: - Minimal confirmation with comment-specific URL -#### `tw thread done ` +#### `cm thread done ` Archive a thread (mark as done). Arguments: -- `thread-ref` - Thread ID or Twist URL +- `thread-ref` - Thread ID or Comms URL Options: @@ -213,7 +213,7 @@ Options: Alias: `convo`. Conversations are DM/group containers. -#### `tw conversation unread [workspace-ref]` +#### `cm conversation unread [workspace-ref]` List unread conversations. @@ -231,13 +231,13 @@ Output format: - URL on second line - No message preview (privacy) -#### `tw conversation view ` +#### `cm conversation view ` Display a conversation with its messages. Arguments: -- `conversation-ref` - Conversation ID or Twist URL +- `conversation-ref` - Conversation ID or Comms URL Options: @@ -247,16 +247,16 @@ Options: - `--raw` - Show raw markdown instead of rendered - `--json` / `--ndjson` - Machine-readable output -#### `tw conversation reply [content]` +#### `cm conversation reply [content]` Send a message in a conversation. Arguments: -- `conversation-ref` - Conversation ID or Twist URL +- `conversation-ref` - Conversation ID or Comms URL - `content` - Message content (optional if using stdin or editor) -Content input: Same as `tw thread reply` (stdin → arg → $EDITOR) +Content input: Same as `cm thread reply` (stdin → arg → $EDITOR) Options: @@ -266,13 +266,13 @@ Output: - Minimal confirmation with message-specific URL -#### `tw conversation done ` +#### `cm conversation done ` Archive a conversation. Arguments: -- `conversation-ref` - Conversation ID or Twist URL +- `conversation-ref` - Conversation ID or Comms URL Options: @@ -284,41 +284,41 @@ Options: Alias: `message`. Operations on individual messages within conversations. -#### `tw msg view ` +#### `cm msg view ` View a single conversation message. Arguments: -- `message-ref` - Message ID or Twist URL +- `message-ref` - Message ID or Comms URL Options: - `--raw` - Show raw markdown instead of rendered - `--json` / `--ndjson` - Machine-readable output -#### `tw msg update [content]` +#### `cm msg update [content]` Edit a conversation message. Arguments: -- `message-ref` - Message ID or Twist URL +- `message-ref` - Message ID or Comms URL - `content` - New message content (optional if using stdin or editor) -Content input: Same as `tw thread reply` (stdin → arg → $EDITOR) +Content input: Same as `cm thread reply` (stdin → arg → $EDITOR) Options: - `--dry-run` - Show what would be updated without updating -#### `tw msg delete ` +#### `cm msg delete ` Delete a conversation message. Arguments: -- `message-ref` - Message ID or Twist URL +- `message-ref` - Message ID or Comms URL Options: @@ -328,7 +328,7 @@ Options: ### Search Commands -#### `tw search [workspace-ref]` +#### `cm search [workspace-ref]` Search content across a workspace. @@ -352,7 +352,7 @@ Options: ### Reaction Commands -#### `tw react ` +#### `cm react ` Add an emoji reaction. @@ -368,13 +368,13 @@ Options: Output displays actual emoji character. -#### `tw unreact ` +#### `cm unreact ` Remove an emoji reaction. Arguments: -- Same as `tw react` +- Same as `cm react` Options: @@ -388,7 +388,7 @@ Commands support these reference formats: - `id:123456` - Direct ID lookup - `123456` - Bare ID (when unambiguous context) -- Full Twist URLs - Parsed to extract IDs +- Full Comms URLs - Parsed to extract IDs - `"Workspace Name"` - Name matching for workspaces only (case-insensitive) Threads, comments, messages, and conversations: **ID or URL only** (no name lookup). @@ -467,7 +467,7 @@ Cursor-based pagination for search: ## Config File -Location: `~/.config/twist-cli/config.json` +Location: `~/.config/comms-cli/config.json` ```json { @@ -483,73 +483,73 @@ Location: `~/.config/twist-cli/config.json` ```bash # Set current workspace -tw workspace use "My Team" +cm workspace use "My Team" # View inbox -tw inbox -tw inbox --unread +cm inbox +cm inbox --unread # View a thread -tw thread view id:123456 -tw thread view https://twist.com/a/12345/ch/67890/t/123456 +cm thread view id:123456 +cm thread view https://comms.todoist.com/a/12345/ch/67890/t/123456 # Reply to a thread -tw thread reply id:123456 "Great idea!" -echo "Multiline\nreply" | tw thread reply id:123456 -tw thread reply id:123456 # opens $EDITOR +cm thread reply id:123456 "Great idea!" +echo "Multiline\nreply" | cm thread reply id:123456 +cm thread reply id:123456 # opens $EDITOR # Mark thread as done -tw thread done id:123456 +cm thread done id:123456 # List unread conversations -tw conversation unread +cm conversation unread # View and reply to a conversation -tw conversation view id:456789 -tw conversation reply id:456789 "Thanks!" +cm conversation view id:456789 +cm conversation reply id:456789 "Thanks!" # Search -tw search "quarterly report" -tw search "bug fix" --author id:123 --since 2024-01-01 +cm search "quarterly report" +cm search "bug fix" --author id:123 --since 2024-01-01 # React to content -tw react thread id:123456 +1 -tw react comment id:789 👍 -tw unreact message id:456 heart +cm react thread id:123456 +1 +cm react comment id:789 👍 +cm unreact message id:456 heart # List channels and users -tw channels -tw users --search "john" +cm channels +cm users --search "john" # Dry run before mutating -tw thread reply id:123 "test" --dry-run -tw thread done id:123 --dry-run +cm thread reply id:123 "test" --dry-run +cm thread done id:123 --dry-run # JSON output for scripting -tw inbox --json -tw search "project" --ndjson +cm inbox --json +cm search "project" --ndjson ``` --- ## Not in MVP (Future Considerations) -- `tw conversation start` - Start new conversations -- `tw thread done --all` - Bulk archive -- `tw link` command - URLs shown in output instead -- `tw open` - Open in browser -- `tw star` / `tw mute` - Star/mute content -- `tw unread` - Unified unread view (threads + messages) +- `cm conversation start` - Start new conversations +- `cm thread done --all` - Bulk archive +- `cm link` command - URLs shown in output instead +- `cm open` - Open in browser +- `cm star` / `cm mute` - Star/mute content +- `cm unread` - Unified unread view (threads + messages) --- ## Implementation Notes -1. **API Client Singleton**: Lazy-initialize `TwistClient` on first use +1. **API Client Singleton**: Lazy-initialize `CommsClient` on first use 2. **Workspace Caching**: Cache current workspace in config, auto-fetch from API default if not set -3. **URL Parsing**: Support full Twist URLs, extract workspace/channel/thread/comment/conversation/message IDs +3. **URL Parsing**: Support full Comms URLs, extract workspace/channel/thread/comment/conversation/message IDs 4. **Batch Operations**: Use `client.batch()` for parallel API calls when fetching related data (channels, users for display) diff --git a/docs/twist-search.md b/docs/comms-search.md similarity index 95% rename from docs/twist-search.md rename to docs/comms-search.md index 98c5e94..da8f80a 100644 --- a/docs/twist-search.md +++ b/docs/comms-search.md @@ -5,13 +5,13 @@ Currently `--author` and `--to` require numeric user IDs: ```bash -tw search "test" --author 440929 +cm search "test" --author 440929 ``` Users should be able to pass names: ```bash -tw search "test" --author craig +cm search "test" --author craig ``` ## Behavior @@ -69,7 +69,7 @@ From `src/lib/api.ts`: ```typescript export async function getWorkspaceUsers(workspaceId: number): Promise { - const client = await getTwistClient() + const client = await getCommsClient() return client.workspaceUsers.getWorkspaceUsers({ workspaceId }) } ``` diff --git a/icons/twist-cli.png b/icons/comms-cli.png similarity index 100% rename from icons/twist-cli.png rename to icons/comms-cli.png diff --git a/icons/twist-cli.svg b/icons/comms-cli.svg similarity index 100% rename from icons/twist-cli.svg rename to icons/comms-cli.svg diff --git a/release.config.js b/release.config.js index 38e1ee5..8607c30 100644 --- a/release.config.js +++ b/release.config.js @@ -30,7 +30,7 @@ export default { 'CHANGELOG.md', 'package.json', 'package-lock.json', - 'skills/twist-cli/SKILL.md', + 'skills/comms-cli/SKILL.md', ], message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md new file mode 100644 index 0000000..0824f22 --- /dev/null +++ b/skills/comms-cli/SKILL.md @@ -0,0 +1,402 @@ +--- +name: comms-cli +description: "Comms messaging CLI. View and respond to inbox threads, channel threads, direct messages, mentions, and group conversations; search, react, archive, mute, and manage workspaces. Use when the user mentions Comms, asks about their inbox, mentions, threads, DMs, channels, or wants to read or send Comms messages." +license: MIT +metadata: + author: Doist + version: "2.41.2" +--- + +# Comms CLI (cm) + +Access Comms messaging via the `cm` CLI. Use when the user asks about their Comms workspaces, threads, messages, or wants to interact with Comms in any way. + +## Setup + +```bash +cm auth login # OAuth login (opens browser, read-write) +cm auth login --read-only # OAuth login with read-only scope +cm auth login --callback-port # Override the local OAuth callback port (default 8766) +cm auth login --json # Emit a JSON envelope for scripted / agent use +cm auth login --ndjson # Emit an NDJSON envelope for scripted / agent use +cm auth token # Save API token manually (prompts securely; scope unknown, assumed write-capable) +cm auth status # Verify authentication + show mode +cm auth status --json # Full status payload as JSON (--ndjson also supported) +cm auth status --user # Target a specific stored account (id, id:, or display name) +cm --user auth # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it +cm auth logout # Remove saved token and auth metadata +cm auth logout --json # Emits `{"ok": true}` (--ndjson is silent) +cm auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND +cm auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set) +cm auth token view --user # Print the saved token for a specific stored account +cm account [list|current|use |remove ] # Manage stored accounts; all support --json/--ndjson + # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"legacy"} +cm auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set +cm workspaces # List available workspaces +cm workspace use # Set current workspace +cm completion install # Install shell completions +cm config view # Show the current CLI configuration file (token masked) +cm config set # Set a user preference (e.g. unarchive-new-threads true) +cm doctor # Diagnose CLI setup and environment issues +cm update # Update CLI to latest version +cm changelog # Show recent changelog entries +``` + +Stored auth uses the system credential manager when available. If secure storage is unavailable, `cm` warns and falls back to `~/.config/comms-cli/config.json`. `COMMS_API_TOKEN` always takes priority over the stored token, and legacy plaintext config tokens are migrated automatically when secure storage is available. + +In read-only mode (`cm 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 `cm auth token`) are treated as unknown scope and assumed write-capable. + +## View by URL + +```bash +cm view # View any Comms entity by URL +``` + +Routes automatically based on URL structure: +- Message URL → `cm msg view` +- Conversation URL → `cm conversation view` +- Thread+comment URL → `cm thread view` (comment ID extracted from URL) +- Thread URL → `cm thread view` + +All target command flags pass through (e.g. `--json`, `--raw`, `--full`). + +## Inbox + +```bash +cm inbox # Show inbox threads +cm inbox --unread # Only unread threads +cm inbox --archive-filter all # Show active + done threads +cm inbox --archive-filter archived # Show only done threads +cm inbox --channel # Filter by channel name (fuzzy) +cm inbox --since # Filter by date (ISO format) +cm inbox --limit # Max items (default: 50) +``` + +## Threads + +```bash +cm thread # View thread (shorthand for view) +cm thread view # View thread with comments +cm thread view --comment # View a specific comment +cm thread view # Comment ID extracted from URL +cm thread view --unread # Show only unread comments +cm thread view --context 3 # Include 3 read comments before unread +cm thread view --limit 20 # Limit number of comments +cm thread view --since # Comments newer than date +cm thread view --raw # Show raw markdown +cm thread create "Title" "content" # Create a new thread +cm thread create "Title" "content" --json # Create and return as JSON +cm thread create "Title" "content" --json --full # Include all thread fields +cm thread create "Title" "content" --notify 123,456 # Notify specific users +cm thread create "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive) +cm thread create "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true +cm thread create "Title" "content" --dry-run # Preview without posting +cm thread reply "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) +cm thread reply "content" --notify EVERYONE # Notify all workspace members +cm thread reply "content" --notify 123,id:456 # Notify specific user IDs +cm thread reply "content" --json # Post and return comment as JSON +cm thread reply "content" --json --full # Include all comment fields +cm thread reply "content" --close # Reply and close the thread +cm thread reply "content" --reopen # Reply and reopen a closed thread +cm thread done # Archive thread (mark done) +cm thread done --json # Archive and return status as JSON +cm thread mute # Mute thread for 60 minutes (default) +cm thread mute --minutes 480 # Mute for custom duration +cm thread mute --json # Mute and return { id, mutedUntil } as JSON +cm thread mute --json --full # Mute and return full thread as JSON +cm thread unmute # Unmute a muted thread +cm thread unmute --json # Unmute and return { id, mutedUntil } as JSON +cm thread delete # Preview thread deletion (requires --yes to execute) +cm thread delete --yes # Permanently delete a thread +cm thread delete --yes --json # Delete and return status as JSON +cm thread rename "New title" # Rename a thread (change its title) +cm thread rename "New title" --json # Rename and return { id, title } as JSON +cm thread rename "New title" --json --full # Rename and return full thread as JSON +cm thread update "New body" # Update a thread's body (the first post) +echo "New body" | cm thread update # Update body from stdin +cm thread update "New body" --dry-run # Preview without updating +cm thread update "New body" --json # Update and return { id, content } as JSON +cm thread update "New body" --json --full # Update and return full thread as JSON +``` + +Default `--notify` for reply is EVERYONE_IN_THREAD, which may notify more people than intended. Before posting, confirm with the user whether specific people should be notified instead (via `--notify `). Options: EVERYONE, EVERYONE_IN_THREAD, or comma-separated ID refs. + +`--notify` automatically resolves IDs: group IDs are routed to the `groups` API field, user IDs to `recipients`. No special syntax needed. + +## Thread Comments + +```bash +cm comment # View a comment (shorthand for view) +cm comment view # View a single thread comment +cm comment view --raw # Show raw markdown +cm comment view --json # Output as JSON +cm comment view --ndjson # Output as newline-delimited JSON +cm comment view --json --full # Include all fields in JSON output +cm comment update "new content" # Update a thread comment +cm comment update "content" --json # Update and return updated comment as JSON +cm comment update "content" --json --full # Include all comment fields +cm comment delete # Delete a thread comment +cm comment delete --json # Delete and return status as JSON +``` + +## Conversations (DMs/Groups) + +```bash +cm conversation unread # List unread conversations +cm conversation # View conversation (shorthand for view) +cm conversation view # View conversation messages +cm conversation with # Find your 1:1 DM with a user +cm conversation with --snippet # Include the latest message preview +cm conversation with --include-groups # List any conversations with that user +cm conversation reply "content" # Send a message +cm conversation reply "content" --json # Send and return message as JSON +cm conversation reply "content" --json --full # Include all message fields +cm conversation done # Archive conversation +cm conversation done --json # Archive and return status as JSON +cm conversation mute # Mute conversation for 60 minutes (default) +cm conversation mute --minutes 480 # Mute for custom duration +cm conversation mute --json # Mute and return { id, mutedUntil } as JSON +cm conversation mute --json --full # Mute and return full conversation as JSON +cm conversation unmute # Unmute a muted conversation +cm conversation unmute --json # Unmute and return { id, mutedUntil } as JSON +``` + +Alias: `cm convo` works the same as `cm conversation`. + +## Conversation Messages + +```bash +cm msg # View a message (shorthand for view) +cm msg view # View a single conversation message +cm msg update "content" # Edit a conversation message +cm msg update "content" --json # Edit and return updated message as JSON +cm msg update "content" --json --full # Include all message fields +cm msg delete # Delete a conversation message +cm msg delete --json # Delete and return status as JSON +``` + +Alias: `cm message` works the same as `cm msg`. + +## Search + +```bash +cm mentions # Show content mentioning current user +cm mentions --since 2026-04-01 --all # Fetch every mention since a date +cm mentions --type threads --json # Limit mentions to threads +cm search "query" # Search content +cm search "query" --type threads # Filter: threads, messages, or all +cm search "query" --author # Filter by author +cm search "query" --to # Messages sent to user +cm search "query" --title-only # Search thread titles only +cm search "query" --mention-me # Results mentioning current user +cm search "query" --conversation # Limit to conversations (comma-separated refs) +cm search "query" --since # Content from date +cm search "query" --until # Content until date +cm search "query" --channel # Filter by channel refs (comma-separated) +cm search "query" --limit # Max results (default: 50) +cm search "query" --cursor # Pagination cursor +cm search "query" --all # Fetch all result pages +``` + +## Users, Channels & Groups + +```bash +cm user # Show current user info +cm user --json # JSON output +cm user --json --full # Include all fields in JSON output +cm users # List workspace users +cm users --search # Filter by name/email +cm channels # List active joined workspace channels (alias of: cm channel list) +cm channels --state all # Include archived joined channels too +cm channels --scope discoverable # Active public channels you can see but have not joined +cm channels --scope public --state all --json # All visible public channels, with joined status +cm channel threads # List threads in a channel (fuzzy name, id:, numeric ID, or URL) +cm channel threads "general" --unread # Only unread threads +cm channel threads --archive-filter all # Include archived threads (active|archived|all) +cm channel threads --since 2026-01-01 # Filter by last-updated date (ISO) +cm channel threads --limit 20 # Max threads per page (default: 50) +cm channel threads --limit 20 --cursor # Paginate +cm channel threads --json # { results, nextCursor } with isUnread + url +cm groups # List workspace groups +cm groups --search "frontend" # Filter groups by name (case-insensitive) +cm groups --json # JSON output +cm groups --json --full # Include all fields in JSON output +cm groups view # Show group with member details +cm groups view --json # JSON output with id, name, workspaceId, members +cm groups view --json --full # Include all fields in JSON output +cm groups create "Name" # Create a new group +cm groups create "Name" --users alice@doist.com,bob@doist.com # Create with members +cm groups create "Name" --json # Output created group as JSON +cm groups rename "New name" # Rename a group +cm groups rename "Name" --json # Output renamed group as JSON +cm groups delete --yes # Delete a group (requires --yes) +cm groups delete --dry-run # Preview deletion +cm groups add-user user1 user2 # Add users to a group +cm groups add-user a@d.com,b@d.com # Comma-separated refs +cm groups add-user id:123 --json # Output result as JSON +cm groups remove-user user1 user2 # Remove users from a group +cm groups remove-user id:123,id:456 # Comma-separated ID refs +``` + +If a channel is not found in `cm channels`, widen with broader listings such as `cm channels --scope public`, then `cm channels --scope public --state all`. Check `cm channels --help` for other available filters. + +`cm channel threads` returns every thread in the channel; pagination filters (`--limit`, `--cursor`, `--since`, `--until`, `--unread`) are applied client-side after fetch. `--archive-filter` is applied server-side. Results are sorted newest-first by last activity. In `--json` / `--ndjson`, the response includes a `nextCursor` string (opaque) you can pass via `--cursor` to fetch the next page; NDJSON emits the cursor as a final `{ "_meta": true, "nextCursor": "..." }` line. + +## Away Status + +```bash +cm away # Show current away status +cm away set [until] # Set away (type: vacation, parental, sickleave, other) +cm away set vacation 2026-03-20 # Away until March 20 +cm away set vacation 2026-03-20 --from 2026-03-15 # Custom start date +cm away clear # Clear away status +``` + +## Reactions + +```bash +cm react thread 👍 # Add reaction to thread +cm react comment +1 # Add reaction (shortcode) +cm react message heart # Add reaction to DM message +cm react thread 👍 --json # Output result as JSON +cm unreact thread 👍 # Remove reaction +cm unreact thread 👍 --json # Output result as JSON +``` + +Supported shortcodes: +1, -1, heart, tada, smile, laughing, thinking, fire, check, x, eyes, pray, clap, rocket, wave + +## Shell Completions + +```bash +cm completion install # Install tab completions (prompts for shell) +cm completion install bash # Install for specific shell +cm completion install zsh +cm completion install fish +cm completion uninstall # Remove completions +``` + +### Diagnostics + +```bash +cm doctor # Run local + network diagnostics +cm doctor --offline # Skip Comms and npm network checks +cm doctor --json # JSON output with per-check results +``` + +### Configuration + +```bash +cm config view # Pretty-printed config, token masked, labels actual token source +cm config view --json # Raw JSON, token masked +cm config view --show-token # Include the full token +cm config set unarchive-new-threads true # Persist: always unarchive new threads so they land in your Inbox +cm config set unarchive-new-threads false # Persist: keep Comms's default (thread auto-archived for author) +``` + +User preferences are stored under `userSettings` in the config file. Currently supported keys: `unarchive-new-threads`. The flag on `cm thread create` (`--unarchive` / `--no-unarchive`) overrides this default per-invocation. + +### Update + +```bash +cm update # Update CLI to latest version +cm update --check # Check for updates without installing, show channel +cm update --check --json # Same, JSON envelope +cm update --check --ndjson # Same, newline-delimited JSON envelope +cm update --channel # Show current update channel +cm update switch --stable # Switch to stable release channel +cm update switch --pre-release # Switch to pre-release (next) channel +cm update switch --pre-release --json # Same, JSON envelope +cm update switch --pre-release --ndjson # Same, newline-delimited JSON envelope +``` + +### Changelog +```bash +cm changelog # Show last 5 versions +cm changelog -n 3 # Show last 3 versions +cm changelog --count 10 # Show last 10 versions +``` + +## Global Options + +```bash +--no-spinner # Disable loading animations +--progress-jsonl # Machine-readable progress events (JSONL to stderr) +--progress-jsonl= # Same, but write events to instead of stderr +--progress-jsonl # Same as above (space-separated form also accepted) +--accessible # Add text labels to color-coded output (also: TW_ACCESSIBLE=1) +--non-interactive # Disable interactive prompts (auto-detected when stdin is not a TTY) +--interactive # Force interactive mode even when stdin is not a TTY +``` + +## Output Formats + +All list/view commands support: + +```bash +--json # Output as JSON +--ndjson # Output as newline-delimited JSON (for streaming) +--full # Include all fields (default shows essential fields only) +``` + +## Dry Run + +Mutating commands accept `--dry-run` to preview the operation without making the change. Where a command performs pre-flight validation (e.g. fetching the target thread to check channel access or ownership), those checks still run in dry-run — only the mutating write is skipped. Commands that have no pre-flight validation parse the reference and print the preview without hitting the API. The preview is structured: + +``` +[dry-run] Would : + : + ... +Run without --dry-run to execute. +``` + +## Reference System + +Commands accept flexible references: +- **Numeric IDs**: `123` or `id:123` +- **Comms URLs**: Full `https://comms.todoist.com/...` URLs (parsed automatically) +- **Fuzzy names**: For workspaces/users - `"My Workspace"` or partial matches + +## Piping Content + +Commands that accept content (`thread create`, `thread reply`, `comment update`, `conversation reply`, `msg update`) auto-detect piped stdin: + +```bash +cat notes.md | cm thread reply +cm thread create "Title" < body.md +echo "Quick reply" | cm conversation reply +``` + +If no content argument is provided and no stdin is piped, the CLI opens `$EDITOR` for interactive input. In non-TTY environments (e.g. when called by an agent or in a pipeline), the editor is automatically skipped and the command fails fast with an actionable error message. Use `--non-interactive` to force this behavior even in a TTY, or `--interactive` to override auto-detection. + +## Common Workflows + +**View by URL (auto-routes to the right command):** +```bash +cm view https://comms.todoist.com/a/1585/ch/100/t/200 # View thread +cm view https://comms.todoist.com/a/1585/ch/100/t/200/c/300 # View comment +cm view https://comms.todoist.com/a/1585/msg/400 # View conversation +cm view https://comms.todoist.com/a/1585/msg/400/m/500 --json # View message as JSON +``` + +**Check inbox and respond:** +```bash +cm inbox --unread --json +cm thread view --unread +cm thread reply "Thanks, I'll look into this." +cm thread done +``` + +**Search and review:** +```bash +cm mentions --since 2026-04-01 --all --json +cm search "deployment" --type threads --json +cm thread view +``` + +**Check DMs:** +```bash +cm conversation unread --json +cm conversation view +cm conversation with "Alice Example" +cm conversation reply "Got it, thanks!" +``` diff --git a/skills/twist-cli/SKILL.md b/skills/twist-cli/SKILL.md deleted file mode 100644 index 09e83c7..0000000 --- a/skills/twist-cli/SKILL.md +++ /dev/null @@ -1,402 +0,0 @@ ---- -name: twist-cli -description: "Twist messaging CLI. View and respond to inbox threads, channel threads, direct messages, mentions, and group conversations; search, react, archive, mute, and manage workspaces. Use when the user mentions Twist, asks about their inbox, mentions, threads, DMs, channels, or wants to read or send Twist messages." -license: MIT -metadata: - author: Doist - version: "2.41.2" ---- - -# Twist CLI (tw) - -Access Twist messaging via the `tw` CLI. Use when the user asks about their Twist workspaces, threads, messages, or wants to interact with Twist in any way. - -## Setup - -```bash -tw auth login # OAuth login (opens browser, read-write) -tw auth login --read-only # OAuth login with read-only scope -tw auth login --callback-port # Override the local OAuth callback port (default 8766) -tw auth login --json # Emit a JSON envelope for scripted / agent use -tw auth login --ndjson # Emit an NDJSON envelope for scripted / agent use -tw auth token # Save API token manually (prompts securely; scope unknown, assumed write-capable) -tw auth status # Verify authentication + show mode -tw auth status --json # Full status payload as JSON (--ndjson also supported) -tw auth status --user # Target a specific stored account (id, id:, or display name) -tw --user auth # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it -tw auth logout # Remove saved token and auth metadata -tw auth logout --json # Emits `{"ok": true}` (--ndjson is silent) -tw auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND -tw auth token view # Print the saved token to stdout (pipe-safe; refuses if TWIST_API_TOKEN is set) -tw auth token view --user # Print the saved token for a specific stored account -tw account [list|current|use |remove ] # Manage stored accounts; all support --json/--ndjson - # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"legacy"} -tw auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set -tw workspaces # List available workspaces -tw workspace use # Set current workspace -tw completion install # Install shell completions -tw config view # Show the current CLI configuration file (token masked) -tw config set # Set a user preference (e.g. unarchive-new-threads true) -tw doctor # Diagnose CLI setup and environment issues -tw update # Update CLI to latest version -tw changelog # Show recent changelog entries -``` - -Stored auth uses the system credential manager when available. If secure storage is unavailable, `tw` warns and falls back to `~/.config/twist-cli/config.json`. `TWIST_API_TOKEN` always takes priority over the stored token, and legacy plaintext config tokens are migrated automatically when secure storage is available. - -In read-only mode (`tw auth login --read-only`), commands that modify Twist data (reply, archive, react, delete, etc.) are blocked by the CLI. Externally provided tokens (`TWIST_API_TOKEN` or `tw auth token`) are treated as unknown scope and assumed write-capable. - -## View by URL - -```bash -tw view # View any Twist entity by URL -``` - -Routes automatically based on URL structure: -- Message URL → `tw msg view` -- Conversation URL → `tw conversation view` -- Thread+comment URL → `tw thread view` (comment ID extracted from URL) -- Thread URL → `tw thread view` - -All target command flags pass through (e.g. `--json`, `--raw`, `--full`). - -## Inbox - -```bash -tw inbox # Show inbox threads -tw inbox --unread # Only unread threads -tw inbox --archive-filter all # Show active + done threads -tw inbox --archive-filter archived # Show only done threads -tw inbox --channel # Filter by channel name (fuzzy) -tw inbox --since # Filter by date (ISO format) -tw inbox --limit # Max items (default: 50) -``` - -## Threads - -```bash -tw thread # View thread (shorthand for view) -tw thread view # View thread with comments -tw thread view --comment # View a specific comment -tw thread view # Comment ID extracted from URL -tw thread view --unread # Show only unread comments -tw thread view --context 3 # Include 3 read comments before unread -tw thread view --limit 20 # Limit number of comments -tw thread view --since # Comments newer than date -tw thread view --raw # Show raw markdown -tw thread create "Title" "content" # Create a new thread -tw thread create "Title" "content" --json # Create and return as JSON -tw thread create "Title" "content" --json --full # Include all thread fields -tw thread create "Title" "content" --notify 123,456 # Notify specific users -tw thread create "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Twist auto-archive) -tw thread create "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true -tw thread create "Title" "content" --dry-run # Preview without posting -tw thread reply "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) -tw thread reply "content" --notify EVERYONE # Notify all workspace members -tw thread reply "content" --notify 123,id:456 # Notify specific user IDs -tw thread reply "content" --json # Post and return comment as JSON -tw thread reply "content" --json --full # Include all comment fields -tw thread reply "content" --close # Reply and close the thread -tw thread reply "content" --reopen # Reply and reopen a closed thread -tw thread done # Archive thread (mark done) -tw thread done --json # Archive and return status as JSON -tw thread mute # Mute thread for 60 minutes (default) -tw thread mute --minutes 480 # Mute for custom duration -tw thread mute --json # Mute and return { id, mutedUntil } as JSON -tw thread mute --json --full # Mute and return full thread as JSON -tw thread unmute # Unmute a muted thread -tw thread unmute --json # Unmute and return { id, mutedUntil } as JSON -tw thread delete # Preview thread deletion (requires --yes to execute) -tw thread delete --yes # Permanently delete a thread -tw thread delete --yes --json # Delete and return status as JSON -tw thread rename "New title" # Rename a thread (change its title) -tw thread rename "New title" --json # Rename and return { id, title } as JSON -tw thread rename "New title" --json --full # Rename and return full thread as JSON -tw thread update "New body" # Update a thread's body (the first post) -echo "New body" | tw thread update # Update body from stdin -tw thread update "New body" --dry-run # Preview without updating -tw thread update "New body" --json # Update and return { id, content } as JSON -tw thread update "New body" --json --full # Update and return full thread as JSON -``` - -Default `--notify` for reply is EVERYONE_IN_THREAD, which may notify more people than intended. Before posting, confirm with the user whether specific people should be notified instead (via `--notify `). Options: EVERYONE, EVERYONE_IN_THREAD, or comma-separated ID refs. - -`--notify` automatically resolves IDs: group IDs are routed to the `groups` API field, user IDs to `recipients`. No special syntax needed. - -## Thread Comments - -```bash -tw comment # View a comment (shorthand for view) -tw comment view # View a single thread comment -tw comment view --raw # Show raw markdown -tw comment view --json # Output as JSON -tw comment view --ndjson # Output as newline-delimited JSON -tw comment view --json --full # Include all fields in JSON output -tw comment update "new content" # Update a thread comment -tw comment update "content" --json # Update and return updated comment as JSON -tw comment update "content" --json --full # Include all comment fields -tw comment delete # Delete a thread comment -tw comment delete --json # Delete and return status as JSON -``` - -## Conversations (DMs/Groups) - -```bash -tw conversation unread # List unread conversations -tw conversation # View conversation (shorthand for view) -tw conversation view # View conversation messages -tw conversation with # Find your 1:1 DM with a user -tw conversation with --snippet # Include the latest message preview -tw conversation with --include-groups # List any conversations with that user -tw conversation reply "content" # Send a message -tw conversation reply "content" --json # Send and return message as JSON -tw conversation reply "content" --json --full # Include all message fields -tw conversation done # Archive conversation -tw conversation done --json # Archive and return status as JSON -tw conversation mute # Mute conversation for 60 minutes (default) -tw conversation mute --minutes 480 # Mute for custom duration -tw conversation mute --json # Mute and return { id, mutedUntil } as JSON -tw conversation mute --json --full # Mute and return full conversation as JSON -tw conversation unmute # Unmute a muted conversation -tw conversation unmute --json # Unmute and return { id, mutedUntil } as JSON -``` - -Alias: `tw convo` works the same as `tw conversation`. - -## Conversation Messages - -```bash -tw msg # View a message (shorthand for view) -tw msg view # View a single conversation message -tw msg update "content" # Edit a conversation message -tw msg update "content" --json # Edit and return updated message as JSON -tw msg update "content" --json --full # Include all message fields -tw msg delete # Delete a conversation message -tw msg delete --json # Delete and return status as JSON -``` - -Alias: `tw message` works the same as `tw msg`. - -## Search - -```bash -tw mentions # Show content mentioning current user -tw mentions --since 2026-04-01 --all # Fetch every mention since a date -tw mentions --type threads --json # Limit mentions to threads -tw search "query" # Search content -tw search "query" --type threads # Filter: threads, messages, or all -tw search "query" --author # Filter by author -tw search "query" --to # Messages sent to user -tw search "query" --title-only # Search thread titles only -tw search "query" --mention-me # Results mentioning current user -tw search "query" --conversation # Limit to conversations (comma-separated refs) -tw search "query" --since # Content from date -tw search "query" --until # Content until date -tw search "query" --channel # Filter by channel refs (comma-separated) -tw search "query" --limit # Max results (default: 50) -tw search "query" --cursor # Pagination cursor -tw search "query" --all # Fetch all result pages -``` - -## Users, Channels & Groups - -```bash -tw user # Show current user info -tw user --json # JSON output -tw user --json --full # Include all fields in JSON output -tw users # List workspace users -tw users --search # Filter by name/email -tw channels # List active joined workspace channels (alias of: tw channel list) -tw channels --state all # Include archived joined channels too -tw channels --scope discoverable # Active public channels you can see but have not joined -tw channels --scope public --state all --json # All visible public channels, with joined status -tw channel threads # List threads in a channel (fuzzy name, id:, numeric ID, or URL) -tw channel threads "general" --unread # Only unread threads -tw channel threads --archive-filter all # Include archived threads (active|archived|all) -tw channel threads --since 2026-01-01 # Filter by last-updated date (ISO) -tw channel threads --limit 20 # Max threads per page (default: 50) -tw channel threads --limit 20 --cursor # Paginate -tw channel threads --json # { results, nextCursor } with isUnread + url -tw groups # List workspace groups -tw groups --search "frontend" # Filter groups by name (case-insensitive) -tw groups --json # JSON output -tw groups --json --full # Include all fields in JSON output -tw groups view # Show group with member details -tw groups view --json # JSON output with id, name, workspaceId, members -tw groups view --json --full # Include all fields in JSON output -tw groups create "Name" # Create a new group -tw groups create "Name" --users alice@doist.com,bob@doist.com # Create with members -tw groups create "Name" --json # Output created group as JSON -tw groups rename "New name" # Rename a group -tw groups rename "Name" --json # Output renamed group as JSON -tw groups delete --yes # Delete a group (requires --yes) -tw groups delete --dry-run # Preview deletion -tw groups add-user user1 user2 # Add users to a group -tw groups add-user a@d.com,b@d.com # Comma-separated refs -tw groups add-user id:123 --json # Output result as JSON -tw groups remove-user user1 user2 # Remove users from a group -tw groups remove-user id:123,id:456 # Comma-separated ID refs -``` - -If a channel is not found in `tw channels`, widen with broader listings such as `tw channels --scope public`, then `tw channels --scope public --state all`. Check `tw channels --help` for other available filters. - -`tw channel threads` returns every thread in the channel; pagination filters (`--limit`, `--cursor`, `--since`, `--until`, `--unread`) are applied client-side after fetch. `--archive-filter` is applied server-side. Results are sorted newest-first by last activity. In `--json` / `--ndjson`, the response includes a `nextCursor` string (opaque) you can pass via `--cursor` to fetch the next page; NDJSON emits the cursor as a final `{ "_meta": true, "nextCursor": "..." }` line. - -## Away Status - -```bash -tw away # Show current away status -tw away set [until] # Set away (type: vacation, parental, sickleave, other) -tw away set vacation 2026-03-20 # Away until March 20 -tw away set vacation 2026-03-20 --from 2026-03-15 # Custom start date -tw away clear # Clear away status -``` - -## Reactions - -```bash -tw react thread 👍 # Add reaction to thread -tw react comment +1 # Add reaction (shortcode) -tw react message heart # Add reaction to DM message -tw react thread 👍 --json # Output result as JSON -tw unreact thread 👍 # Remove reaction -tw unreact thread 👍 --json # Output result as JSON -``` - -Supported shortcodes: +1, -1, heart, tada, smile, laughing, thinking, fire, check, x, eyes, pray, clap, rocket, wave - -## Shell Completions - -```bash -tw completion install # Install tab completions (prompts for shell) -tw completion install bash # Install for specific shell -tw completion install zsh -tw completion install fish -tw completion uninstall # Remove completions -``` - -### Diagnostics - -```bash -tw doctor # Run local + network diagnostics -tw doctor --offline # Skip Twist and npm network checks -tw doctor --json # JSON output with per-check results -``` - -### Configuration - -```bash -tw config view # Pretty-printed config, token masked, labels actual token source -tw config view --json # Raw JSON, token masked -tw config view --show-token # Include the full token -tw config set unarchive-new-threads true # Persist: always unarchive new threads so they land in your Inbox -tw config set unarchive-new-threads false # Persist: keep Twist's default (thread auto-archived for author) -``` - -User preferences are stored under `userSettings` in the config file. Currently supported keys: `unarchive-new-threads`. The flag on `tw thread create` (`--unarchive` / `--no-unarchive`) overrides this default per-invocation. - -### Update - -```bash -tw update # Update CLI to latest version -tw update --check # Check for updates without installing, show channel -tw update --check --json # Same, JSON envelope -tw update --check --ndjson # Same, newline-delimited JSON envelope -tw update --channel # Show current update channel -tw update switch --stable # Switch to stable release channel -tw update switch --pre-release # Switch to pre-release (next) channel -tw update switch --pre-release --json # Same, JSON envelope -tw update switch --pre-release --ndjson # Same, newline-delimited JSON envelope -``` - -### Changelog -```bash -tw changelog # Show last 5 versions -tw changelog -n 3 # Show last 3 versions -tw changelog --count 10 # Show last 10 versions -``` - -## Global Options - -```bash ---no-spinner # Disable loading animations ---progress-jsonl # Machine-readable progress events (JSONL to stderr) ---progress-jsonl= # Same, but write events to instead of stderr ---progress-jsonl # Same as above (space-separated form also accepted) ---accessible # Add text labels to color-coded output (also: TW_ACCESSIBLE=1) ---non-interactive # Disable interactive prompts (auto-detected when stdin is not a TTY) ---interactive # Force interactive mode even when stdin is not a TTY -``` - -## Output Formats - -All list/view commands support: - -```bash ---json # Output as JSON ---ndjson # Output as newline-delimited JSON (for streaming) ---full # Include all fields (default shows essential fields only) -``` - -## Dry Run - -Mutating commands accept `--dry-run` to preview the operation without making the change. Where a command performs pre-flight validation (e.g. fetching the target thread to check channel access or ownership), those checks still run in dry-run — only the mutating write is skipped. Commands that have no pre-flight validation parse the reference and print the preview without hitting the API. The preview is structured: - -``` -[dry-run] Would : - : - ... -Run without --dry-run to execute. -``` - -## Reference System - -Commands accept flexible references: -- **Numeric IDs**: `123` or `id:123` -- **Twist URLs**: Full `https://twist.com/...` URLs (parsed automatically) -- **Fuzzy names**: For workspaces/users - `"My Workspace"` or partial matches - -## Piping Content - -Commands that accept content (`thread create`, `thread reply`, `comment update`, `conversation reply`, `msg update`) auto-detect piped stdin: - -```bash -cat notes.md | tw thread reply -tw thread create "Title" < body.md -echo "Quick reply" | tw conversation reply -``` - -If no content argument is provided and no stdin is piped, the CLI opens `$EDITOR` for interactive input. In non-TTY environments (e.g. when called by an agent or in a pipeline), the editor is automatically skipped and the command fails fast with an actionable error message. Use `--non-interactive` to force this behavior even in a TTY, or `--interactive` to override auto-detection. - -## Common Workflows - -**View by URL (auto-routes to the right command):** -```bash -tw view https://twist.com/a/1585/ch/100/t/200 # View thread -tw view https://twist.com/a/1585/ch/100/t/200/c/300 # View comment -tw view https://twist.com/a/1585/msg/400 # View conversation -tw view https://twist.com/a/1585/msg/400/m/500 --json # View message as JSON -``` - -**Check inbox and respond:** -```bash -tw inbox --unread --json -tw thread view --unread -tw thread reply "Thanks, I'll look into this." -tw thread done -``` - -**Search and review:** -```bash -tw mentions --since 2026-04-01 --all --json -tw search "deployment" --type threads --json -tw thread view -``` - -**Check DMs:** -```bash -tw conversation unread --json -tw conversation view -tw conversation with "Alice Example" -tw conversation reply "Got it, thanks!" -``` diff --git a/src/commands/account/account.test.ts b/src/commands/account/account.test.ts index bb1fce6..411dcfc 100644 --- a/src/commands/account/account.test.ts +++ b/src/commands/account/account.test.ts @@ -11,23 +11,18 @@ const storeMocks = vi.hoisted(() => ({ getLastClearResult: vi.fn(), })) -const legacyMocks = vi.hoisted(() => ({ - isLegacyAuthActive: vi.fn(), -})) - vi.mock('../../lib/auth-provider.js', async (importOriginal) => { const actual = await importOriginal() return { ...actual, createCommsTokenStore: () => storeMocks, - isLegacyAuthActive: legacyMocks.isLegacyAuthActive, } }) vi.mock('chalk') import { ACCOUNT_ALAN, ACCOUNT_ELLIE } from '../../lib/__fixtures__/accounts.js' -import { type CommsAccount } from '../../lib/auth-provider.js' +import type { CommsAccount } from '../../lib/auth-provider.js' import { TOKEN_ENV_VAR } from '../../lib/auth.js' import { registerAccountCommand } from './index.js' @@ -51,18 +46,12 @@ function seedStore(...records: Array): storeMocks.getLastClearResult.mockReturnValue({ storage: 'secure-store' }) } -const LEGACY_SNAPSHOT = { - token: 'tk_legacy', - account: { id: '', label: '', authMode: 'unknown' as const, authScope: '' }, -} - describe('account command', () => { let consoleSpy: ReturnType let errorSpy: ReturnType beforeEach(() => { vi.clearAllMocks() - legacyMocks.isLegacyAuthActive.mockResolvedValue(false) consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) }) @@ -79,7 +68,7 @@ describe('account command', () => { it('renders all stored accounts with the default marker', async () => { seedStore([ACCOUNT_ALAN, 'default'], ACCOUNT_ELLIE) - await createProgram().parseAsync(['node', 'tw', 'account', 'list']) + await createProgram().parseAsync(['node', 'cm', 'account', 'list']) const output = stdout() expect(output).toContain('Stored accounts (2)') @@ -90,10 +79,10 @@ describe('account command', () => { expect(output).toContain('Default: id:1 Alan Grant') }) - it('runs by default when no subcommand is given (tw account)', async () => { + it('runs by default when no subcommand is given (cm account)', async () => { seedStore([ACCOUNT_ALAN, 'default']) - await createProgram().parseAsync(['node', 'tw', 'account']) + await createProgram().parseAsync(['node', 'cm', 'account']) expect(stdout()).toContain('Stored accounts (1)') }) @@ -101,17 +90,17 @@ describe('account command', () => { it('reports the empty state when no accounts are stored', async () => { seedStore() - await createProgram().parseAsync(['node', 'tw', 'account', 'list']) + await createProgram().parseAsync(['node', 'cm', 'account', 'list']) expect(consoleSpy).toHaveBeenCalledWith( - 'No stored accounts. Run `tw auth login` to add one.', + 'No stored accounts. Run `cm auth login` to add one.', ) }) it('emits a JSON envelope with id, label, isDefault', async () => { seedStore([ACCOUNT_ALAN, 'default'], ACCOUNT_ELLIE) - await createProgram().parseAsync(['node', 'tw', 'account', 'list', '--json']) + await createProgram().parseAsync(['node', 'cm', 'account', 'list', '--json']) expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual([ { id: '1', label: 'Alan Grant', isDefault: true }, @@ -125,7 +114,7 @@ describe('account command', () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.active.mockResolvedValue({ token: 'tk_abc', account: ACCOUNT_ALAN }) - await createProgram().parseAsync(['node', 'tw', 'account', 'current']) + await createProgram().parseAsync(['node', 'cm', 'account', 'current']) const output = stdout() expect(output).toContain('Active account: id:1 Alan Grant') @@ -138,7 +127,7 @@ describe('account command', () => { async (flag) => { vi.stubEnv(TOKEN_ENV_VAR, 'tk_env_supplied') - await createProgram().parseAsync(['node', 'tw', 'account', 'current', flag]) + await createProgram().parseAsync(['node', 'cm', 'account', 'current', flag]) expect(consoleSpy).toHaveBeenCalledTimes(1) expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ source: 'env' }) @@ -146,43 +135,12 @@ describe('account command', () => { }, ) - it('renders a legacy-session notice when active() returns an empty-id snapshot', async () => { - vi.stubEnv(TOKEN_ENV_VAR, '') - storeMocks.active.mockResolvedValue(LEGACY_SNAPSHOT) - - await createProgram().parseAsync(['node', 'tw', 'account', 'current']) - - expect(stdout()).toContain('legacy single-user session') - }) - - it('emits {source:"legacy"} in --json mode for legacy snapshots', async () => { - vi.stubEnv(TOKEN_ENV_VAR, '') - storeMocks.active.mockResolvedValue(LEGACY_SNAPSHOT) - - await createProgram().parseAsync(['node', 'tw', 'account', 'current', '--json']) - - expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ source: 'legacy' }) - }) - - it('reports a populated legacy snapshot (authUserId set) as legacy, not config', async () => { - // `readLegacyTokenSnapshot` populates id/label from v1 flat - // fields when present, so the empty-id check alone misses this - // case — `isLegacyAuthActive()` is the authoritative signal. - vi.stubEnv(TOKEN_ENV_VAR, '') - legacyMocks.isLegacyAuthActive.mockResolvedValue(true) - storeMocks.active.mockResolvedValue({ token: 'tk_legacy', account: ACCOUNT_ALAN }) - - await createProgram().parseAsync(['node', 'tw', 'account', 'current', '--json']) - - expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ source: 'legacy' }) - }) - it('throws NO_TOKEN when nothing is active', async () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.active.mockResolvedValue(null) await expect( - createProgram().parseAsync(['node', 'tw', 'account', 'current']), + createProgram().parseAsync(['node', 'cm', 'account', 'current']), ).rejects.toHaveProperty('code', 'NO_TOKEN') }) @@ -190,7 +148,7 @@ describe('account command', () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.active.mockResolvedValue({ token: 'tk_abc', account: ACCOUNT_ALAN }) - await createProgram().parseAsync(['node', 'tw', 'account', 'current', '--json']) + await createProgram().parseAsync(['node', 'cm', 'account', 'current', '--json']) expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ id: '1', @@ -206,7 +164,7 @@ describe('account command', () => { it('sets the default account by canonical id when the ref matches', async () => { seedStore(ACCOUNT_ALAN, [ACCOUNT_ELLIE, 'default']) - await createProgram().parseAsync(['node', 'tw', 'account', 'use', '1']) + await createProgram().parseAsync(['node', 'cm', 'account', 'use', '1']) expect(storeMocks.setDefault).toHaveBeenCalledTimes(1) expect(storeMocks.setDefault).toHaveBeenCalledWith('1') @@ -219,7 +177,7 @@ describe('account command', () => { seedStore([ACCOUNT_ALAN, 'default']) await expect( - createProgram().parseAsync(['node', 'tw', 'account', 'use', '999']), + createProgram().parseAsync(['node', 'cm', 'account', 'use', '999']), ).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND') expect(storeMocks.setDefault).not.toHaveBeenCalled() @@ -228,7 +186,7 @@ describe('account command', () => { it('matches refs by display name and resolves to the canonical id', async () => { seedStore(ACCOUNT_ALAN, [ACCOUNT_ELLIE, 'default']) - await createProgram().parseAsync(['node', 'tw', 'account', 'use', 'alan grant']) + await createProgram().parseAsync(['node', 'cm', 'account', 'use', 'alan grant']) expect(storeMocks.setDefault).toHaveBeenCalledTimes(1) const output = stdout() @@ -241,7 +199,7 @@ describe('account command', () => { it('clears the account by canonical id and prints the removed label', async () => { seedStore([ACCOUNT_ALAN, 'default'], ACCOUNT_ELLIE) - await createProgram().parseAsync(['node', 'tw', 'account', 'remove', 'ellie sattler']) + await createProgram().parseAsync(['node', 'cm', 'account', 'remove', 'ellie sattler']) expect(storeMocks.clear).toHaveBeenCalledTimes(1) expect(storeMocks.clear).toHaveBeenCalledWith('2') @@ -254,7 +212,7 @@ describe('account command', () => { seedStore([ACCOUNT_ALAN, 'default']) await expect( - createProgram().parseAsync(['node', 'tw', 'account', 'remove', '999']), + createProgram().parseAsync(['node', 'cm', 'account', 'remove', '999']), ).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND') expect(storeMocks.clear).not.toHaveBeenCalled() @@ -267,7 +225,7 @@ describe('account command', () => { warning: 'system credential manager unavailable; local auth state cleared', }) - await createProgram().parseAsync(['node', 'tw', 'account', 'remove', '1']) + await createProgram().parseAsync(['node', 'cm', 'account', 'remove', '1']) expect(errorSpy).toHaveBeenCalledWith( 'Warning:', @@ -278,7 +236,7 @@ describe('account command', () => { it('emits a JSON envelope and suppresses the plain confirmation', async () => { seedStore([ACCOUNT_ALAN, 'default']) - await createProgram().parseAsync(['node', 'tw', 'account', 'remove', '1', '--json']) + await createProgram().parseAsync(['node', 'cm', 'account', 'remove', '1', '--json']) expect(consoleSpy).toHaveBeenCalledTimes(1) expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ @@ -288,24 +246,4 @@ describe('account command', () => { }) }) }) - - // One per gated command — assertV2Available is shared, but a regression - // could remove the guard from any single handler. - describe.each([ - ['list', ['list']], - ['use', ['use', '1']], - ['remove', ['remove', '1']], - ])('%s refuses while legacy auth is still active', (_name, args) => { - it('throws AUTH_MIGRATION_PENDING without touching the store', async () => { - legacyMocks.isLegacyAuthActive.mockResolvedValue(true) - - await expect( - createProgram().parseAsync(['node', 'tw', 'account', ...args]), - ).rejects.toHaveProperty('code', 'AUTH_MIGRATION_PENDING') - - expect(storeMocks.list).not.toHaveBeenCalled() - expect(storeMocks.setDefault).not.toHaveBeenCalled() - expect(storeMocks.clear).not.toHaveBeenCalled() - }) - }) }) diff --git a/src/commands/account/current.ts b/src/commands/account/current.ts index c51c226..180d94d 100644 --- a/src/commands/account/current.ts +++ b/src/commands/account/current.ts @@ -1,6 +1,6 @@ import { emitView } from '@doist/cli-core' import chalk from 'chalk' -import { isLegacyAuthActive, type CommsTokenStore } from '../../lib/auth-provider.js' +import type { CommsTokenStore } from '../../lib/auth-provider.js' import { TOKEN_ENV_VAR } from '../../lib/auth.js' import { CliError } from '../../lib/errors.js' import type { ViewOptions } from '../../lib/options.js' @@ -16,22 +16,11 @@ export async function currentAccount(options: ViewOptions, store: CommsTokenStor const snapshot = await store.active() if (!snapshot) { throw new CliError('NO_TOKEN', 'No stored account is currently active.', [ - 'Run: tw auth login', + 'Run: cm auth login', ]) } const { account } = snapshot - // Snapshot can still be the v1 legacy fallback when migration is - // inconclusive — even when id/label are populated from the old flat - // `authUserId` / `authUserName` fields. Treat that as legacy too. - if (!account.id || !account.label || (await isLegacyAuthActive())) { - emitView(options, { source: 'legacy' }, () => [ - 'Active token is a legacy single-user session (pre-multi-account).', - chalk.dim('Run `tw auth status` while online to migrate it into the v2 store.'), - ]) - return - } - emitView( options, { diff --git a/src/commands/account/helpers.ts b/src/commands/account/helpers.ts deleted file mode 100644 index 9bcb531..0000000 --- a/src/commands/account/helpers.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { isLegacyAuthActive } from '../../lib/auth-provider.js' -import { CliError } from '../../lib/errors.js' - -/** - * Account-management commands operate against the v2 user-records store. - * When `migrateLegacyAuth` couldn't complete (typically offline), the v2 - * store is empty and `ACCOUNT_NOT_FOUND` would be misleading — fail with - * a dedicated envelope so callers get an actionable hint. - */ -export async function assertV2Available(): Promise { - if (await isLegacyAuthActive()) { - throw new CliError( - 'AUTH_MIGRATION_PENDING', - 'Cannot manage accounts while the legacy single-user token is still active. ' + - 'The CLI could not complete the v1 → v2 auth migration (usually because Twist was unreachable).', - [ - 'Run `tw auth status` while online to finish the migration', - 'Or run `tw auth logout` followed by `tw auth login` to start fresh', - ], - ) - } -} diff --git a/src/commands/account/index.ts b/src/commands/account/index.ts index e9d6c23..a47449e 100644 --- a/src/commands/account/index.ts +++ b/src/commands/account/index.ts @@ -42,8 +42,8 @@ export function registerAccountCommand(program: Command): void { 'after', ` Examples: - tw account # list stored accounts (default subcommand) - tw account use "Alan Grant" # pin Alan as the default account (id, id:N, or name) - tw account remove id:42 # forget id:42 (clears keyring + config entry)`, + cm account # list stored accounts (default subcommand) + cm account use "Alan Grant" # pin Alan as the default account (id, id:N, or name) + cm account remove id:42 # forget id:42 (clears keyring + config entry)`, ) } diff --git a/src/commands/account/list.ts b/src/commands/account/list.ts index 75964d4..1f70016 100644 --- a/src/commands/account/list.ts +++ b/src/commands/account/list.ts @@ -2,10 +2,8 @@ import chalk from 'chalk' import type { CommsTokenStore } from '../../lib/auth-provider.js' import type { ViewOptions } from '../../lib/options.js' import { formatJson, formatNdjson } from '../../lib/output.js' -import { assertV2Available } from './helpers.js' export async function listAccounts(options: ViewOptions, store: CommsTokenStore): Promise { - await assertV2Available() const records = await store.list() const rows = records.map(({ account, isDefault }) => ({ id: account.id, @@ -17,7 +15,7 @@ export async function listAccounts(options: ViewOptions, store: CommsTokenStore) if (options.ndjson) return console.log(formatNdjson(rows)) if (rows.length === 0) { - console.log('No stored accounts. Run `tw auth login` to add one.') + console.log('No stored accounts. Run `cm auth login` to add one.') return } diff --git a/src/commands/account/remove.ts b/src/commands/account/remove.ts index a949251..61de42b 100644 --- a/src/commands/account/remove.ts +++ b/src/commands/account/remove.ts @@ -3,14 +3,12 @@ import chalk from 'chalk' import { findAccountInStore, type CommsTokenStore } from '../../lib/auth-provider.js' import type { ViewOptions } from '../../lib/options.js' import { logTokenStorageResult } from '../auth/helpers.js' -import { assertV2Available } from './helpers.js' export async function removeAccount( ref: string, options: ViewOptions, store: CommsTokenStore, ): Promise { - await assertV2Available() const account = await findAccountInStore(store, ref) await store.clear(account.id) diff --git a/src/commands/account/use.ts b/src/commands/account/use.ts index bb28fff..9536072 100644 --- a/src/commands/account/use.ts +++ b/src/commands/account/use.ts @@ -2,14 +2,12 @@ import { emitView } from '@doist/cli-core' import chalk from 'chalk' import { findAccountInStore, type CommsTokenStore } from '../../lib/auth-provider.js' import type { ViewOptions } from '../../lib/options.js' -import { assertV2Available } from './helpers.js' export async function useAccount( ref: string, options: ViewOptions, store: CommsTokenStore, ): Promise { - await assertV2Available() const account = await findAccountInStore(store, ref) await store.setDefault(account.id) diff --git a/src/commands/auth/auth.test.ts b/src/commands/auth/auth.test.ts index 49f5c01..189c443 100644 --- a/src/commands/auth/auth.test.ts +++ b/src/commands/auth/auth.test.ts @@ -81,12 +81,12 @@ import { type CommsAccount, type CommsTokenStore } from '../../lib/auth-provider import { getAuthMetadata, TOKEN_ENV_VAR } from '../../lib/auth.js' import { resetGlobalArgs } from '../../lib/global-args.js' import { registerAuthCommand } from './index.js' -import { attachTwistStatusCommand } from './status.js' +import { attachCommsStatusCommand } from './status.js' const mockCreateInterface = vi.mocked(createInterface) const mockGetAuthMetadata = vi.mocked(getAuthMetadata) -const mockCreateWrappedTwistClient = vi.mocked(createWrappedCommsClient) +const mockCreateWrappedCommsClient = vi.mocked(createWrappedCommsClient) const mockAttachLoginCommand = vi.mocked(attachLoginCommand) function createProgram() { @@ -143,7 +143,7 @@ describe('auth command', () => { it('saves the token via store.set with an empty-id account, trims whitespace, and confirms', async () => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'auth', 'token', ' some_token_123456789 ']) + await program.parseAsync(['node', 'cm', 'auth', 'token', ' some_token_123456789 ']) expect(storeMocks.set).toHaveBeenCalledWith( { id: '', label: '', authMode: 'unknown', authScope: '' }, @@ -160,7 +160,7 @@ describe('auth command', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'auth', 'token', 'some_token_123456789']), + program.parseAsync(['node', 'cm', 'auth', 'token', 'some_token_123456789']), ).rejects.toThrow('Permission denied') }) @@ -177,7 +177,7 @@ describe('auth command', () => { mockCreateInterface.mockReturnValue(mockRl as unknown as Interface) const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - await createProgram().parseAsync(['node', 'tw', 'auth', 'token']) + await createProgram().parseAsync(['node', 'cm', 'auth', 'token']) expect(mockRl.question).toHaveBeenCalled() expect(storeMocks.set).toHaveBeenCalledWith( @@ -200,7 +200,7 @@ describe('auth command', () => { await createProgram().parseAsync([ 'node', - 'tw', + 'cm', 'auth', 'token', 'some_token_123456789', @@ -225,13 +225,13 @@ describe('auth command', () => { const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) await expect( - createProgram().parseAsync(['node', 'tw', 'auth', 'token']), + createProgram().parseAsync(['node', 'cm', 'auth', 'token']), ).rejects.toHaveProperty('code', 'NO_TOKEN') // non-interactive (no TTY, no arg) Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }) await expect( - createProgram().parseAsync(['node', 'tw', 'auth', 'token']), + createProgram().parseAsync(['node', 'cm', 'auth', 'token']), ).rejects.toHaveProperty('code', 'NO_TOKEN') expect(storeMocks.set).not.toHaveBeenCalled() @@ -268,7 +268,7 @@ describe('auth command', () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.active.mockResolvedValue(STORED_SNAPSHOT) - await createProgram().parseAsync(['node', 'tw', 'auth', 'token', 'view']) + await createProgram().parseAsync(['node', 'cm', 'auth', 'token', 'view']) expect(stdoutPayload()).toBe('tk_stored_1234567890') expect(consoleSpy).not.toHaveBeenCalled() @@ -278,7 +278,7 @@ describe('auth command', () => { vi.stubEnv(TOKEN_ENV_VAR, 'env_token_supplied_externally') await expect( - createProgram().parseAsync(['node', 'tw', 'auth', 'token', 'view']), + createProgram().parseAsync(['node', 'cm', 'auth', 'token', 'view']), ).rejects.toHaveProperty('code', 'TOKEN_FROM_ENV') expect(storeMocks.active).not.toHaveBeenCalled() @@ -290,7 +290,7 @@ describe('auth command', () => { storeMocks.active.mockResolvedValue(null) await expect( - createProgram().parseAsync(['node', 'tw', 'auth', 'token', 'view']), + createProgram().parseAsync(['node', 'cm', 'auth', 'token', 'view']), ).rejects.toHaveProperty('code', 'NOT_AUTHENTICATED') expect(stdoutPayload()).toBe('') @@ -300,7 +300,7 @@ describe('auth command', () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.active.mockResolvedValue(STORED_SNAPSHOT) - await createProgram().parseAsync(['node', 'tw', 'auth', 'token', 'view', '--user', '1']) + await createProgram().parseAsync(['node', 'cm', 'auth', 'token', 'view', '--user', '1']) expect(storeMocks.active).toHaveBeenCalledWith('1') expect(stdoutPayload()).toBe('tk_stored_1234567890') @@ -313,7 +313,7 @@ describe('auth command', () => { await expect( createProgram().parseAsync([ 'node', - 'tw', + 'cm', 'auth', 'token', 'view', @@ -345,14 +345,14 @@ describe('auth command', () => { vi.unstubAllEnvs() }) - it('threads `tw --user auth token view` into store.active', async () => { + it('threads `cm --user auth token view` into store.active', async () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.list.mockResolvedValue(STORED_RECORDS) storeMocks.active.mockResolvedValue(STORED_SNAPSHOT) - process.argv = ['node', 'tw', '--user', '1', 'auth', 'token', 'view'] + process.argv = ['node', 'cm', '--user', '1', 'auth', 'token', 'view'] resetGlobalArgs() - await createProgram().parseAsync(['node', 'tw', 'auth', 'token', 'view']) + await createProgram().parseAsync(['node', 'cm', 'auth', 'token', 'view']) expect(storeMocks.active).toHaveBeenCalledWith('1') expect(writeSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('')).toBe( @@ -360,11 +360,11 @@ describe('auth command', () => { ) }) - it('threads `tw --user auth status` into the snapshot used by fetchLive', async () => { + it('threads `cm --user auth status` into the snapshot used by fetchLive', async () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.list.mockResolvedValue(STORED_RECORDS) storeMocks.active.mockResolvedValue(STORED_SNAPSHOT) - mockCreateWrappedTwistClient.mockReturnValue({ + mockCreateWrappedCommsClient.mockReturnValue({ users: { getSessionUser: vi.fn().mockResolvedValue(TEST_USER) }, // biome-ignore lint/suspicious/noExplicitAny: only the methods used in this test matter } as any) @@ -373,17 +373,17 @@ describe('auth command', () => { authScope: 'user:read', source: 'config', }) - process.argv = ['node', 'tw', '--user', '1', 'auth', 'status'] + process.argv = ['node', 'cm', '--user', '1', 'auth', 'status'] resetGlobalArgs() - await createProgram().parseAsync(['node', 'tw', 'auth', 'status']) + await createProgram().parseAsync(['node', 'cm', 'auth', 'status']) expect(storeMocks.active).toHaveBeenCalledWith('1') - expect(mockCreateWrappedTwistClient).toHaveBeenCalledWith('tk_stored_1234567890') + expect(mockCreateWrappedCommsClient).toHaveBeenCalledWith('tk_stored_1234567890') expect(consoleSpy).toHaveBeenCalledWith('✓ Authenticated') }) - it('blocks `tw --user auth logout` with ACCOUNT_NOT_FOUND before touching storage', async () => { + it('blocks `cm --user auth logout` with ACCOUNT_NOT_FOUND before touching storage', async () => { // `withUserRefAware` validates the global ref against `store.list()` // before substituting it into the store call, so a non-matching ref // surfaces as a typed miss instead of cli-core's silent clear no-op. @@ -397,12 +397,12 @@ describe('auth command', () => { }, }, ]) - process.argv = ['node', 'tw', '--user', '999', 'auth', 'logout'] + process.argv = ['node', 'cm', '--user', '999', 'auth', 'logout'] resetGlobalArgs() const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'auth', 'logout']), + program.parseAsync(['node', 'cm', 'auth', 'logout']), ).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND') expect(storeMocks.clear).not.toHaveBeenCalled() @@ -411,7 +411,7 @@ describe('auth command', () => { describe('status subcommand', () => { // All happy-path tests drive a controllable snapshot store directly - // into `attachTwistStatusCommand` so the `fetchLive` → + // into `attachCommsStatusCommand` so the `fetchLive` → // `renderText` / `renderJson` path is covered without relying on // process state (env vars, secure store) leaking from the host. The // `onNotAuthenticated` branch is exercised below via `createProgram()`, @@ -440,12 +440,12 @@ describe('auth command', () => { getLastStorageResult: () => undefined, getLastClearResult: () => undefined, } - attachTwistStatusCommand(auth, snapshotStore) + attachCommsStatusCommand(auth, snapshotStore) return program } beforeEach(() => { - mockCreateWrappedTwistClient.mockReturnValue({ + mockCreateWrappedCommsClient.mockReturnValue({ users: { getSessionUser: vi.fn().mockResolvedValue(TEST_USER) }, // biome-ignore lint/suspicious/noExplicitAny: only the methods used in this test matter } as any) @@ -457,9 +457,9 @@ describe('auth command', () => { }) it('renders text status from the snapshot', async () => { - await programWithSnapshot().parseAsync(['node', 'tw', 'auth', 'status']) + await programWithSnapshot().parseAsync(['node', 'cm', 'auth', 'status']) - expect(mockCreateWrappedTwistClient).toHaveBeenCalledWith('snapshot_token') + expect(mockCreateWrappedCommsClient).toHaveBeenCalledWith('snapshot_token') expect(consoleSpy).toHaveBeenCalledWith('✓ Authenticated') expect(consoleSpy).toHaveBeenCalledWith(' Email: test@example.com') expect(consoleSpy).toHaveBeenCalledWith(' Name: Test User') @@ -467,7 +467,7 @@ describe('auth command', () => { }) it('emits the JSON envelope from the snapshot path', async () => { - await programWithSnapshot().parseAsync(['node', 'tw', 'auth', 'status', '--json']) + await programWithSnapshot().parseAsync(['node', 'cm', 'auth', 'status', '--json']) const printed = consoleSpy.mock.calls[0][0] as string expect(JSON.parse(printed)).toEqual({ @@ -481,7 +481,7 @@ describe('auth command', () => { }) it('emits a single newline-free NDJSON line from the snapshot path', async () => { - await programWithSnapshot().parseAsync(['node', 'tw', 'auth', 'status', '--ndjson']) + await programWithSnapshot().parseAsync(['node', 'cm', 'auth', 'status', '--ndjson']) // NDJSON must be one JSON value per line — assert one console.log // call whose payload contains no embedded newline (would slip @@ -504,7 +504,7 @@ describe('auth command', () => { // with 401 — the shared `gatherStatusData` 401 branch must wrap // it in the standard `NO_TOKEN` envelope so users see the // "re-authenticate" hint, not a raw `CommsRequestError`. - mockCreateWrappedTwistClient.mockReturnValue({ + mockCreateWrappedCommsClient.mockReturnValue({ users: { getSessionUser: vi .fn() @@ -514,7 +514,7 @@ describe('auth command', () => { } as any) await expect( - programWithSnapshot().parseAsync(['node', 'tw', 'auth', 'status']), + programWithSnapshot().parseAsync(['node', 'cm', 'auth', 'status']), ).rejects.toHaveProperty('code', 'NO_TOKEN') }) @@ -538,16 +538,16 @@ describe('auth command', () => { getLastStorageResult: () => undefined, getLastClearResult: () => undefined, } - attachTwistStatusCommand(auth, emptyStore) + attachCommsStatusCommand(auth, emptyStore) await expect( - program.parseAsync(['node', 'tw', 'auth', 'status']), + program.parseAsync(['node', 'cm', 'auth', 'status']), ).rejects.toHaveProperty('code', 'NO_TOKEN') }) }) describe('login subcommand wiring', () => { - it('passes the twist provider, store, port, and renderers to cli-core attachLoginCommand', async () => { + it('passes the comms provider, store, port, and renderers to cli-core attachLoginCommand', async () => { createProgram() expect(mockAttachLoginCommand).toHaveBeenCalledTimes(1) @@ -591,7 +591,7 @@ describe('auth command', () => { }) it('clears the API token', async () => { - await createProgram().parseAsync(['node', 'tw', 'auth', 'logout']) + await createProgram().parseAsync(['node', 'cm', 'auth', 'logout']) expect(storeMocks.clear).toHaveBeenCalled() expect(consoleSpy).toHaveBeenCalledWith('✓ Logged out') @@ -603,7 +603,7 @@ describe('auth command', () => { it('surfaces keyring-fallback warning to stderr in plain mode', async () => { storeMocks.getLastClearResult.mockReturnValue(WARNING_RESULT) - await createProgram().parseAsync(['node', 'tw', 'auth', 'logout']) + await createProgram().parseAsync(['node', 'cm', 'auth', 'logout']) expect(consoleSpy).toHaveBeenCalledWith('✓ Logged out') expect(errorSpy).toHaveBeenCalledWith('Warning:', WARNING_RESULT.warning) @@ -612,7 +612,7 @@ describe('auth command', () => { it('routes warning to stderr and emits JSON envelope on stdout in --json mode', async () => { storeMocks.getLastClearResult.mockReturnValue(WARNING_RESULT) - await createProgram().parseAsync(['node', 'tw', 'auth', 'logout', '--json']) + await createProgram().parseAsync(['node', 'cm', 'auth', 'logout', '--json']) const stdoutLines = consoleSpy.mock.calls.map((c: unknown[]) => String(c[0])) expect(stdoutLines).toHaveLength(1) @@ -625,7 +625,7 @@ describe('auth command', () => { it('routes warning to stderr and keeps stdout silent in --ndjson mode', async () => { storeMocks.getLastClearResult.mockReturnValue(WARNING_RESULT) - await createProgram().parseAsync(['node', 'tw', 'auth', 'logout', '--ndjson']) + await createProgram().parseAsync(['node', 'cm', 'auth', 'logout', '--ndjson']) expect(consoleSpy).not.toHaveBeenCalled() expect(errorSpy).toHaveBeenCalledWith('Warning:', WARNING_RESULT.warning) diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index 2946cee..d55fd3e 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -3,9 +3,9 @@ import { Command } from 'commander' import { createCommsTokenStore } from '../../lib/auth-provider.js' import { TOKEN_ENV_VAR } from '../../lib/auth.js' import { getRequestedUserRef } from '../../lib/global-args.js' -import { attachTwistLoginCommand } from './login.js' -import { attachTwistLogoutCommand } from './logout.js' -import { attachTwistStatusCommand } from './status.js' +import { attachCommsLoginCommand } from './login.js' +import { attachCommsLogoutCommand } from './logout.js' +import { attachCommsStatusCommand } from './status.js' import { withUserRefAware } from './store-wrap.js' import { loginWithToken } from './token.js' @@ -15,14 +15,14 @@ export function registerAuthCommand(program: Command): void { const store = createCommsTokenStore() const refAware = withUserRefAware(store, getRequestedUserRef()) - attachTwistLoginCommand(auth, store) - attachTwistLogoutCommand(auth, refAware) - attachTwistStatusCommand(auth, refAware) + attachCommsLoginCommand(auth, store) + attachCommsLogoutCommand(auth, refAware) + attachCommsStatusCommand(auth, refAware) // `token` is a hybrid: the positional `[token]` saves, and the `view` // subcommand prints. Commander matches subcommand names before the parent - // action, so `tw auth token view` always dispatches to the view path — - // Twist OAuth tokens are opaque random strings so the literal "view" can + // action, so `cm auth token view` always dispatches to the view path — + // Comms OAuth tokens are opaque random strings so the literal "view" can // never collide with a real token value. const tokenCmd = auth .command('token [token]') diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 61680d9..0f1dd22 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -3,7 +3,7 @@ import chalk from 'chalk' import type { Command } from 'commander' import { renderError, renderSuccess } from '../../lib/auth-pages.js' import { - createTwistAuthProvider, + createCommsAuthProvider, READ_ONLY_SCOPES, READ_WRITE_SCOPES, type CommsTokenStore, @@ -12,8 +12,8 @@ import { logTokenStorageResult } from './helpers.js' const PREFERRED_CALLBACK_PORT = 8766 -export function attachTwistLoginCommand(parent: Command, store: CommsTokenStore): Command { - const provider = createTwistAuthProvider() +export function attachCommsLoginCommand(parent: Command, store: CommsTokenStore): Command { + const provider = createCommsAuthProvider() return attachLoginCommand(parent, { provider, diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index cc288bb..2cc0bf7 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -4,13 +4,13 @@ import type { CommsAccount, CommsTokenStore } from '../../lib/auth-provider.js' import { logTokenStorageResult } from './helpers.js' /** - * Attach `tw auth logout` via cli-core's generic `attachLogoutCommand`. The + * Attach `cm auth logout` via cli-core's generic `attachLogoutCommand`. The * registrar emits the success line (`✓ Logged out` / `{ok:true}` / silent * ndjson); `onCleared` only surfaces the keyring-fallback warning carried by * `TokenStorageResult` — cli-core's `TokenStore.clear: void` contract can't * expose it directly, so we stash it on the adapter (`getLastClearResult`). */ -export function attachTwistLogoutCommand(auth: Command, store: CommsTokenStore): Command { +export function attachCommsLogoutCommand(auth: Command, store: CommsTokenStore): Command { return attachLogoutCommand(auth, { store, onCleared: ({ view }) => { diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 4f45308..3cc028a 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -40,7 +40,7 @@ async function gatherStatusData(token: string): Promise { } catch (error) { if (error instanceof CommsRequestError && error.httpStatusCode === 401) { throw new CliError('NO_TOKEN', 'Not authenticated (token expired or invalid)', [ - 'Run `tw auth login` to re-authenticate', + 'Run `cm auth login` to re-authenticate', ]) } throw error @@ -69,18 +69,18 @@ function buildStatusJson({ user, metadata }: StatusData): Record(auth, { diff --git a/src/commands/auth/store-wrap.ts b/src/commands/auth/store-wrap.ts index 2b0953b..5bb584c 100644 --- a/src/commands/auth/store-wrap.ts +++ b/src/commands/auth/store-wrap.ts @@ -1,7 +1,7 @@ import type { AccountRef } from '@doist/cli-core/auth' import { findAccountInStore, type CommsTokenStore } from '../../lib/auth-provider.js' -// Bridge the global `tw --user ` (stripped by `src/index.ts`) into +// Bridge the global `cm --user ` (stripped by `src/index.ts`) into // cli-core's attachers, which only see per-command `--user`. Explicit ref // passed by commander wins over the captured global ref. // @@ -10,7 +10,7 @@ import { findAccountInStore, type CommsTokenStore } from '../../lib/auth-provide // surface via `onNotAuthenticated` (status / token view). `clear()` does the // extra existence check first via `findAccountInStore`, because cli-core's // `KeyringTokenStore.clear` is a silent no-op on a non-matching ref and -// would otherwise let `tw --user auth logout` print `✓ Logged out`. +// would otherwise let `cm --user auth logout` print `✓ Logged out`. export function withUserRefAware( store: CommsTokenStore, requestedRef: AccountRef | undefined, diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 7a1b991..640526d 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -40,9 +40,9 @@ export async function loginWithToken(token?: string): Promise { const trimmed = token.trim() if (!trimmed) { throw new CliError('NO_TOKEN', 'No token provided', [ - 'Run: tw auth token (interactive prompt)', + 'Run: cm auth token (interactive prompt)', 'Or set COMMS_API_TOKEN environment variable', - 'Or use OAuth: tw auth login', + 'Or use OAuth: cm auth login', ]) } // Manual token entry has no identity (no API call to resolve the user). diff --git a/src/commands/away/away.test.ts b/src/commands/away/away.test.ts index 399f195..ab4b27e 100644 --- a/src/commands/away/away.test.ts +++ b/src/commands/away/away.test.ts @@ -48,7 +48,7 @@ describe('away', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'away']) + await program.parseAsync(['node', 'cm', 'away']) expect(logSpy).toHaveBeenCalledWith('Not away.') logSpy.mockRestore() @@ -63,7 +63,7 @@ describe('away', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'away']) + await program.parseAsync(['node', 'cm', 'away']) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Vacation')) logSpy.mockRestore() @@ -75,7 +75,7 @@ describe('away', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'away', 'set', 'vacation', '2026-03-20']) + await program.parseAsync(['node', 'cm', 'away', 'set', 'vacation', '2026-03-20']) expect(apiMocks.updateUser).toHaveBeenCalledWith( expect.objectContaining({ @@ -94,7 +94,7 @@ describe('away', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'away', 'set', 'vacation', @@ -115,7 +115,7 @@ describe('away', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'away', 'set', 'vacation', @@ -137,7 +137,7 @@ describe('away', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'away', 'set', 'vacation', '2026-03-20']), + program.parseAsync(['node', 'cm', 'away', 'set', 'vacation', '2026-03-20']), ).rejects.toThrow(scopeError) }) @@ -145,7 +145,7 @@ describe('away', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'away', 'set', 'invalid', '2026-03-20']), + program.parseAsync(['node', 'cm', 'away', 'set', 'invalid', '2026-03-20']), ).rejects.toHaveProperty('code', 'INVALID_TYPE') }) }) @@ -155,7 +155,7 @@ describe('away', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'away', 'clear']) + await program.parseAsync(['node', 'cm', 'away', 'clear']) expect(apiMocks.updateUser).toHaveBeenCalledWith({ awayMode: '' }) expect(logSpy).toHaveBeenCalledWith('Away status cleared.') @@ -166,7 +166,7 @@ describe('away', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'away', 'clear', '--dry-run']) + await program.parseAsync(['node', 'cm', 'away', 'clear', '--dry-run']) expect(apiMocks.updateUser).not.toHaveBeenCalled() expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Would clear away status')) diff --git a/src/commands/away/index.ts b/src/commands/away/index.ts index e6ed23a..e2fc36e 100644 --- a/src/commands/away/index.ts +++ b/src/commands/away/index.ts @@ -14,8 +14,8 @@ export function registerAwayCommand(program: Command): void { 'after', ` Examples: - tw away - tw away --json`, + cm away + cm away --json`, ) .action((options: ViewOptions) => showAwayStatus(options)) @@ -30,9 +30,9 @@ Examples: 'after', ` Examples: - tw away set vacation 2025-12-31 - tw away set sickleave --from 2025-06-01 - tw away set other 2025-07-01 --dry-run`, + cm away set vacation 2025-12-31 + cm away set sickleave --from 2025-06-01 + cm away set other 2025-07-01 --dry-run`, ) .action( ( @@ -51,8 +51,8 @@ Examples: 'after', ` Examples: - tw away clear - tw away clear --dry-run`, + cm away clear + cm away clear --dry-run`, ) .action((options: MutationOptions & ViewOptions) => clearAway(options)) } diff --git a/src/commands/changelog.test.ts b/src/commands/changelog.test.ts index b2713f0..97a0302 100644 --- a/src/commands/changelog.test.ts +++ b/src/commands/changelog.test.ts @@ -9,7 +9,7 @@ import { registerChangelogCommand } from './changelog.js' const mockReadFile = vi.mocked(readFile) -// Fixture exercises the three twist-specific options: +// Fixture exercises the three comms-specific options: // - headingLevel: 'flexible' — accepts both `# 1.x` and `## 1.x` // - continuationIndent: true — wrapped-bullet line is indented under bullet // - filterEmptyVersions: true — deps-only release is dropped from output @@ -51,20 +51,20 @@ describe('changelog wrapper', () => { mockReadFile.mockReset() }) - it('passes the twist CHANGELOG.md path through to cli-core', async () => { + it('passes the comms CHANGELOG.md path through to cli-core', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - await createProgram().parseAsync(['node', 'tw', 'changelog', '-n', '1']) + await createProgram().parseAsync(['node', 'cm', 'changelog', '-n', '1']) expect(mockReadFile).toHaveBeenCalledTimes(1) const [path] = mockReadFile.mock.calls[0] expect(String(path)).toMatch(/\/CHANGELOG\.md$/) }) - it('emits a footer link pointing at the twist repo and current version', async () => { + it('emits a footer link pointing at the comms repo and current version', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - await createProgram().parseAsync(['node', 'tw', 'changelog', '-n', '1']) + await createProgram().parseAsync(['node', 'cm', 'changelog', '-n', '1']) const all = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n') expect(all).toContain( @@ -75,7 +75,7 @@ describe('changelog wrapper', () => { it('renders both # and ## version headings (headingLevel: flexible)', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - await createProgram().parseAsync(['node', 'tw', 'changelog', '-n', '5']) + await createProgram().parseAsync(['node', 'cm', 'changelog', '-n', '5']) const all = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n') expect(all).toContain('9.9.0') @@ -89,7 +89,7 @@ describe('changelog wrapper', () => { it('drops deps-only versions (filterEmptyVersions: true)', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - await createProgram().parseAsync(['node', 'tw', 'changelog', '-n', '5']) + await createProgram().parseAsync(['node', 'cm', 'changelog', '-n', '5']) const all = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n') expect(all).not.toContain('9.8.5') @@ -99,7 +99,7 @@ describe('changelog wrapper', () => { it('indents continuation lines under bullets (continuationIndent: true)', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - await createProgram().parseAsync(['node', 'tw', 'changelog', '-n', '1']) + await createProgram().parseAsync(['node', 'cm', 'changelog', '-n', '1']) const all = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n') // Continuation line should be indented under the bullet (more diff --git a/src/commands/channel/channel.test.ts b/src/commands/channel/channel.test.ts index 0f3432e..87b67f1 100644 --- a/src/commands/channel/channel.test.ts +++ b/src/commands/channel/channel.test.ts @@ -84,7 +84,7 @@ describe('channels list', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'channels', 'Doist', '--workspace', 'Other']), + program.parseAsync(['node', 'cm', 'channels', 'Doist', '--workspace', 'Other']), ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') }) @@ -99,7 +99,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channels']) + await program.parseAsync(['node', 'cm', 'channels']) expect(client.channels.getChannels).toHaveBeenCalledWith({ workspaceId: 1, @@ -121,7 +121,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel']) + await program.parseAsync(['node', 'cm', 'channel']) expect(client.channels.getChannels).toHaveBeenCalledWith({ workspaceId: 1, @@ -140,7 +140,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel', 'list']) + await program.parseAsync(['node', 'cm', 'channel', 'list']) expect(client.channels.getChannels).toHaveBeenCalledWith({ workspaceId: 1, @@ -162,7 +162,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channels']) + await program.parseAsync(['node', 'cm', 'channels']) expect(consoleSpy).toHaveBeenCalledTimes(2) expect(client.channels.getChannels).toHaveBeenCalledWith({ @@ -191,7 +191,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channels', '--scope', 'public']) + await program.parseAsync(['node', 'cm', 'channels', '--scope', 'public']) expect(client.channels.getChannels).toHaveBeenCalledWith({ workspaceId: 1 }) expect(client.workspaces.getPublicChannels).toHaveBeenCalledWith(1) @@ -214,7 +214,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channels', '--scope', 'discoverable', '--json']) + await program.parseAsync(['node', 'cm', 'channels', '--scope', 'discoverable', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual([ @@ -232,7 +232,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channels', '--state', 'archived']) + await program.parseAsync(['node', 'cm', 'channels', '--state', 'archived']) expect(client.channels.getChannels).toHaveBeenCalledWith({ workspaceId: 1, archived: true }) expect(consoleSpy).toHaveBeenCalledTimes(1) @@ -256,7 +256,7 @@ describe('channels list', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'channels', '--scope', 'public', @@ -285,7 +285,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channels', '--state', 'all', '--json']) + await program.parseAsync(['node', 'cm', 'channels', '--state', 'all', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual([ @@ -307,7 +307,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channels', '--state', 'all', '--ndjson']) + await program.parseAsync(['node', 'cm', 'channels', '--state', 'all', '--ndjson']) const ndjsonOutput = consoleSpy.mock.calls[0][0] .split('\n') @@ -331,7 +331,7 @@ describe('channels list', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'channels', '--scope', 'public', @@ -357,7 +357,7 @@ describe('channels list', () => { }, run: async (extraArgs) => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'channels', ...extraArgs]) + await program.parseAsync(['node', 'cm', 'channels', ...extraArgs]) }, humanMessage: 'No active channels found.', }) @@ -374,7 +374,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channels', '--scope', 'discoverable']) + await program.parseAsync(['node', 'cm', 'channels', '--scope', 'discoverable']) expect(consoleSpy).toHaveBeenCalledWith('No active discoverable channels found.') @@ -387,7 +387,7 @@ describe('channels list', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'channels', '--scope', 'invalid']), + program.parseAsync(['node', 'cm', 'channels', '--scope', 'invalid']), ).rejects.toHaveProperty('code', 'INVALID_SCOPE') }) @@ -397,7 +397,7 @@ describe('channels list', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'channels', '--state', 'invalid']), + program.parseAsync(['node', 'cm', 'channels', '--state', 'invalid']), ).rejects.toHaveProperty('code', 'INVALID_STATE') }) }) diff --git a/src/commands/channel/index.ts b/src/commands/channel/index.ts index fa15046..ec5ead3 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -28,13 +28,13 @@ export function registerChannelCommand(program: Command): void { 'after', ` Examples: - tw channels - tw channels --state all - tw channels --scope discoverable - tw channels --scope public --state archived - tw channels --scope public --state all --json - tw channels --json - tw channels "My Workspace" --scope discoverable --json + cm channels + cm channels --state all + cm channels --scope discoverable + cm channels --scope public --state archived + cm channels --scope public --state all --json + cm channels --json + cm channels "My Workspace" --scope discoverable --json Notes: Defaults to active channels that you have joined. @@ -45,7 +45,7 @@ Notes: all Both active and archived channels archived Archived channels only - Twist does not expose unjoined private channels, so public/discoverable scopes never include them.`, + Comms does not expose unjoined private channels, so public/discoverable scopes never include them.`, ) .action(listChannels) @@ -74,12 +74,12 @@ Notes: 'after', ` Examples: - tw channel threads 12345 - tw channel threads "general" - tw channel threads id:12345 --unread - tw channel threads 12345 --archive-filter all --since 2026-01-01 - tw channel threads 12345 --limit 20 --json - tw channel threads 12345 --limit 20 --cursor + cm channel threads 12345 + cm channel threads "general" + cm channel threads id:12345 --unread + cm channel threads 12345 --archive-filter all --since 2026-01-01 + cm channel threads 12345 --limit 20 --json + cm channel threads 12345 --limit 20 --cursor Notes: Sorted newest-first by last activity. --limit, --cursor, --since, --until, diff --git a/src/commands/channel/threads.test.ts b/src/commands/channel/threads.test.ts index ff1ff52..60e5b13 100644 --- a/src/commands/channel/threads.test.ts +++ b/src/commands/channel/threads.test.ts @@ -124,7 +124,7 @@ describe('channel threads', () => { await expect( program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', 'general', @@ -139,7 +139,7 @@ describe('channel threads', () => { setupClient() const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel', 'threads', 'general', '--json']) + await program.parseAsync(['node', 'cm', 'channel', 'threads', 'general', '--json']) expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('general', 1) }) @@ -149,7 +149,7 @@ describe('channel threads', () => { setupClient() const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel', 'threads', 'general', 'Doist', '--json']) + await program.parseAsync(['node', 'cm', 'channel', 'threads', 'general', 'Doist', '--json']) expect(refsMocks.resolveWorkspaceRef).toHaveBeenCalledWith('Doist') expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('general', 42) @@ -159,7 +159,7 @@ describe('channel threads', () => { const { mockGetThreads } = setupClient() const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) expect(mockGetThreads).toHaveBeenCalledWith( { workspaceId: 1, channelId: 100, archived: false }, @@ -173,7 +173,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', '12345', @@ -194,7 +194,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', '12345', @@ -217,7 +217,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results.find((t: { id: number }) => t.id === 1).isUnread).toBe(false) @@ -237,7 +237,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', '12345', @@ -265,7 +265,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', '12345', @@ -293,7 +293,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', '12345', @@ -319,7 +319,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results.map((t: { id: number }) => t.id)).toEqual([2, 3, 1]) @@ -342,7 +342,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', '12345', @@ -373,7 +373,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', '12345', @@ -398,7 +398,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.nextCursor).toBeNull() @@ -411,7 +411,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) expect(vi.mocked(assertChannelIsPublic)).toHaveBeenCalledWith(100, 1) @@ -426,7 +426,7 @@ describe('channel threads', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']), + program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']), ).rejects.toThrow('This thread belongs to a private channel.') }) @@ -437,7 +437,7 @@ describe('channel threads', () => { await expect( program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', '12345', @@ -455,7 +455,7 @@ describe('channel threads', () => { await expect( program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', '12345', @@ -473,7 +473,7 @@ describe('channel threads', () => { await expect( program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', '12345', @@ -490,7 +490,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel', 'threads', 'general']) + await program.parseAsync(['node', 'cm', 'channel', 'threads', 'general']) expect(consoleSpy).toHaveBeenCalledWith('No threads in #general.') @@ -505,7 +505,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results[0]).toMatchObject({ @@ -530,7 +530,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'channel', 'threads', '12345', @@ -557,7 +557,7 @@ describe('channel threads', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']), + program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']), ).resolves.not.toThrow() const output = JSON.parse(consoleSpy.mock.calls[0][0]) @@ -582,7 +582,7 @@ describe('channel threads', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json']), + program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']), ).rejects.toThrow('Failed to fetch threads: Channel access denied') }) @@ -593,7 +593,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'channel', 'threads', '12345', '--json', '--full']) + await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json', '--full']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results[0]).toHaveProperty('pinned', true) diff --git a/src/commands/comment/comment.test.ts b/src/commands/comment/comment.test.ts index 36f85d0..e738be5 100644 --- a/src/commands/comment/comment.test.ts +++ b/src/commands/comment/comment.test.ts @@ -91,11 +91,11 @@ describe('comment implicit view', () => { apiMocks.getCommsClient.mockRejectedValue(new Error('MOCK_API_REACHED')) }) - it('tw comment routes to view (not unknown command)', async () => { + it('cm comment routes to view (not unknown command)', async () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await expect(program.parseAsync(['node', 'tw', 'comment', '300'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'cm', 'comment', '300'])).rejects.toThrow( 'MOCK_API_REACHED', ) @@ -114,7 +114,7 @@ describe('comment view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'comment', 'view', '300']) + await program.parseAsync(['node', 'cm', 'comment', 'view', '300']) expect(client.comments.getComment).toHaveBeenCalledWith(300) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Comment 300')) @@ -127,7 +127,7 @@ describe('comment view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'comment', 'view', '300', '--json']) + await program.parseAsync(['node', 'cm', 'comment', 'view', '300', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(300) @@ -141,7 +141,7 @@ describe('comment view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'comment', 'view', '300', '--ndjson']) + await program.parseAsync(['node', 'cm', 'comment', 'view', '300', '--ndjson']) const line = consoleSpy.mock.calls[0][0] expect(line).not.toContain('\n') @@ -157,7 +157,7 @@ describe('comment view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'comment', 'view', '300', '--json', '--full']) + await program.parseAsync(['node', 'cm', 'comment', 'view', '300', '--json', '--full']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(300) @@ -177,7 +177,7 @@ describe('comment update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'comment', 'update', '300', 'Updated content']) + await program.parseAsync(['node', 'cm', 'comment', 'update', '300', 'Updated content']) expect(client.comments.updateComment).toHaveBeenCalledWith({ id: 300, @@ -193,7 +193,7 @@ describe('comment update', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'comment', 'update', '300', @@ -213,7 +213,7 @@ describe('comment update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'comment', 'update', '300', 'Updated', '--json']) + await program.parseAsync(['node', 'cm', 'comment', 'update', '300', 'Updated', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(300) @@ -228,7 +228,7 @@ describe('comment update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'comment', 'update', '300']) + await program.parseAsync(['node', 'cm', 'comment', 'update', '300']) expect(client.comments.updateComment).toHaveBeenCalledWith({ id: 300, @@ -242,7 +242,7 @@ describe('comment update', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await expect( - program.parseAsync(['node', 'tw', 'comment', 'update', '300']), + program.parseAsync(['node', 'cm', 'comment', 'update', '300']), ).rejects.toHaveProperty('code', 'MISSING_CONTENT') consoleSpy.mockRestore() @@ -260,7 +260,7 @@ describe('comment delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'comment', 'delete', '300']) + await program.parseAsync(['node', 'cm', 'comment', 'delete', '300']) expect(client.comments.deleteComment).toHaveBeenCalledWith(300) expect(consoleSpy).toHaveBeenCalledWith('Comment 300 deleted.') @@ -273,7 +273,7 @@ describe('comment delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'comment', 'delete', '300', '--dry-run']) + await program.parseAsync(['node', 'cm', 'comment', 'delete', '300', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would delete comment')) expect(consoleSpy).toHaveBeenCalledWith(' Comment: 300') @@ -288,7 +288,7 @@ describe('comment delete', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'comment', 'delete', '300', '--dry-run']), + program.parseAsync(['node', 'cm', 'comment', 'delete', '300', '--dry-run']), ).rejects.toHaveProperty('code', 'NOT_CREATOR') expect(client.comments.deleteComment).not.toHaveBeenCalled() }) @@ -302,7 +302,7 @@ describe('comment delete', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'comment', 'delete', '300', '--dry-run']), + program.parseAsync(['node', 'cm', 'comment', 'delete', '300', '--dry-run']), ).rejects.toThrow('channel is private') expect(client.comments.deleteComment).not.toHaveBeenCalled() }) @@ -313,7 +313,7 @@ describe('comment delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'comment', 'delete', '300', '--json']) + await program.parseAsync(['node', 'cm', 'comment', 'delete', '300', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual({ id: 300, deleted: true }) diff --git a/src/commands/comment/index.ts b/src/commands/comment/index.ts index 6bb7511..011b06e 100644 --- a/src/commands/comment/index.ts +++ b/src/commands/comment/index.ts @@ -19,8 +19,8 @@ export function registerCommentCommand(program: Command): void { 'after', ` Examples: - tw comment 12345 - tw comment view 12345 --json`, + cm comment 12345 + cm comment view 12345 --json`, ) .action((ref, options) => { if (!ref) { @@ -40,9 +40,9 @@ Examples: 'after', ` Examples: - tw comment update 12345 "Updated text" - echo "New content" | tw comment update 12345 - tw comment update 12345 "Fixed" --json`, + cm comment update 12345 "Updated text" + echo "New content" | cm comment update 12345 + cm comment update 12345 "Fixed" --json`, ) .action(updateComment) @@ -55,8 +55,8 @@ Examples: 'after', ` Examples: - tw comment delete 12345 - tw comment delete 12345 --dry-run`, + cm comment delete 12345 + cm comment delete 12345 --dry-run`, ) .action(deleteComment) } diff --git a/src/commands/completion/helpers.ts b/src/commands/completion/helpers.ts index a88349b..8c01984 100644 --- a/src/commands/completion/helpers.ts +++ b/src/commands/completion/helpers.ts @@ -13,7 +13,7 @@ export const COMPLETION_EXTENSIONS: Record = { } /** - * Find which shells have tw completions installed by checking for the + * Find which shells have cm completions installed by checking for the * completion script files that tabtab creates. * * FIXME: Workaround for https://github.com/pnpm/tabtab/issues/34 — @@ -24,7 +24,7 @@ export const COMPLETION_EXTENSIONS: Record = { export function installedShells(): SupportedShell[] { return Object.entries(COMPLETION_EXTENSIONS) .filter(([shell, ext]) => - existsSync(join(homedir(), '.config', 'tabtab', shell, `tw.${ext}`)), + existsSync(join(homedir(), '.config', 'tabtab', shell, `cm.${ext}`)), ) .map(([shell]) => shell as SupportedShell) } @@ -32,7 +32,7 @@ export function installedShells(): SupportedShell[] { export function resolveCompleterCommand(): string { const invokedScript = process.argv[1] if (!invokedScript) { - return 'tw' + return 'cm' } const resolvedScript = resolve(invokedScript) @@ -40,5 +40,5 @@ export function resolveCompleterCommand(): string { return resolvedScript } - return 'tw' + return 'cm' } diff --git a/src/commands/completion/install.ts b/src/commands/completion/install.ts index 3bbddda..f3d828b 100644 --- a/src/commands/completion/install.ts +++ b/src/commands/completion/install.ts @@ -14,9 +14,9 @@ export async function installCompletion(shell?: string): Promise { } await tabtab.install({ - name: 'tw', + name: 'cm', // Use the executable path used to install completions so shell - // completion doesn't accidentally call an older `tw` on PATH. + // completion doesn't accidentally call an older `cm` on PATH. completer, shell: shell as SupportedShell, }) diff --git a/src/commands/completion/uninstall.ts b/src/commands/completion/uninstall.ts index f6efd8e..4fe8aee 100644 --- a/src/commands/completion/uninstall.ts +++ b/src/commands/completion/uninstall.ts @@ -1,7 +1,7 @@ import { installedShells } from './helpers.js' export async function uninstallCompletion(): Promise { - // FIXME: Replace with plain tabtab.uninstall({ name: 'tw' }) once + // FIXME: Replace with plain tabtab.uninstall({ name: 'cm' }) once // https://github.com/pnpm/tabtab/issues/34 is fixed. const shells = installedShells() if (shells.length === 0) { @@ -12,7 +12,7 @@ export async function uninstallCompletion(): Promise { const tabtab = await import('@pnpm/tabtab') for (const shell of shells) { await tabtab.uninstall({ - name: 'tw', + name: 'cm', shell, }) } diff --git a/src/commands/config/config.test.ts b/src/commands/config/config.test.ts index 2c9f510..bdb20eb 100644 --- a/src/commands/config/config.test.ts +++ b/src/commands/config/config.test.ts @@ -40,9 +40,8 @@ function createProgram() { } const fullConfig: Config = { - token: 'tw_abcdefghij1234567890', - authMode: 'read-write', - authScope: 'user:read', + users: [{ ...STORED_ALAN, token: 'tw_abcdefghij1234567890' }], + defaultUserId: STORED_ALAN.id, currentWorkspace: 12345, updateChannel: 'stable', } @@ -64,7 +63,7 @@ function mockToken( }> = {}, ) { mockProbeApiToken.mockResolvedValue({ - token: overrides.token ?? (fullConfig.token as string), + token: overrides.token ?? 'tw_abcdefghij1234567890', metadata: { authMode: overrides.authMode ?? 'read-write', authScope: overrides.authScope, @@ -83,7 +82,7 @@ describe('config view', () => { mockToken('config-file', { authScope: 'user:read' }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('/tmp/fake-comms-cli/config.json') @@ -101,11 +100,11 @@ describe('config view', () => { }) it('labels tokens stored in the system credential manager', async () => { - presentConfig({ authMode: 'read-write' }) + presentConfig({ users: [STORED_ALAN], defaultUserId: STORED_ALAN.id }) mockToken('secure-store', { token: 'tw_keychainXXXXXXXX1234' }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('****…1234') @@ -116,16 +115,16 @@ describe('config view', () => { }) it('labels env-sourced tokens and shows active mode, not stale config values', async () => { - // Config has a stale read-only entry from a previous `tw auth login`, + // Config has a stale read-only entry from a previous `cm auth login`, // but COMMS_API_TOKEN is now driving auth with an unknown scope. presentConfig({ - authMode: 'read-only', - authScope: 'user:read', + users: [STORED_ELLIE], + defaultUserId: STORED_ELLIE.id, }) mockToken('env', { token: 'tw_envXXXXXXXX5678', authMode: 'unknown' }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('****…5678') @@ -148,7 +147,7 @@ describe('config view', () => { mockToken('env', { token: 'tw_envXXXXXXXX9999', authMode: 'unknown' }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('/tmp/fake-comms-cli/config.json') @@ -164,7 +163,7 @@ describe('config view', () => { mockToken('config-file') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config']) + await createProgram().parseAsync(['node', 'cm', 'config']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Authentication') @@ -174,11 +173,11 @@ describe('config view', () => { }) it('degrades gracefully when the credential manager is unavailable', async () => { - presentConfig({ authMode: 'read-write', updateChannel: 'stable' }) + presentConfig({ users: [STORED_ALAN], updateChannel: 'stable' }) mockProbeApiToken.mockRejectedValue(new SecureStoreUnavailableError('macOS Keychain error')) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('unknown') @@ -188,15 +187,14 @@ describe('config view', () => { consoleSpy.mockRestore() }) - it('--json emits the raw config with token masked', async () => { + it('--json emits the raw config with per-user tokens masked', async () => { presentConfig() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json']) const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) - expect(parsed.token).toBe('****…7890') - expect(parsed.authMode).toBe('read-write') + expect(parsed.users[0].token).toBe('****…7890') expect(parsed.currentWorkspace).toBe(12345) consoleSpy.mockRestore() @@ -207,14 +205,14 @@ describe('config view', () => { mockToken('config-file') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--show-token']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--show-token']) const pretty = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(pretty).toContain('tw_abcdefghij1234567890') consoleSpy.mockClear() - await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json', '--show-token']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json', '--show-token']) const json = JSON.parse(consoleSpy.mock.calls[0][0] as string) - expect(json.token).toBe('tw_abcdefghij1234567890') + expect(json.users[0].token).toBe('tw_abcdefghij1234567890') consoleSpy.mockRestore() }) @@ -224,11 +222,11 @@ describe('config view', () => { mockProbeApiToken.mockRejectedValue(new NoTokenError()) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) expect(consoleSpy.mock.calls[0][0]).toContain('not created yet') consoleSpy.mockClear() - await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json']) expect(consoleSpy.mock.calls[0][0]).toBe('{}') consoleSpy.mockRestore() @@ -245,7 +243,7 @@ describe('config view', () => { mockProbeApiToken.mockRejectedValue(new NoTokenError()) await expect( - createProgram().parseAsync(['node', 'tw', 'config', 'view']), + createProgram().parseAsync(['node', 'cm', 'config', 'view']), ).rejects.toMatchObject({ code: 'CONFIG_INVALID_JSON' }) }) @@ -254,7 +252,7 @@ describe('config view', () => { mockProbeApiToken.mockRejectedValue(new NoTokenError()) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('not set') expect(output).toContain('stable') @@ -263,13 +261,13 @@ describe('config view', () => { }) it('masks very short tokens without exposing characters', async () => { - presentConfig({ token: 'abcd' }) + presentConfig({ users: [{ ...STORED_ALAN, token: 'abcd' }] }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json']) const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) - expect(parsed.token).toBe('****') - expect(parsed.token).not.toContain('abcd') + expect(parsed.users[0].token).toBe('****') + expect(parsed.users[0].token).not.toContain('abcd') consoleSpy.mockRestore() }) @@ -279,7 +277,7 @@ describe('config view', () => { mockToken('secure-store') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Authenticated accounts (2)') @@ -301,7 +299,7 @@ describe('config view', () => { mockToken('secure-store') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') const adaLine = output.split('\n').find((l) => l.includes('Alan Grant')) ?? '' @@ -319,7 +317,7 @@ describe('config view', () => { mockToken('secure-store') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') const alanLine = output.split('\n').find((l) => l.includes('Alan Grant')) ?? '' @@ -330,11 +328,11 @@ describe('config view', () => { }) it('omits the accounts block when config.users is empty or absent', async () => { - presentConfig({ authMode: 'read-write' }) + presentConfig({ currentWorkspace: 1 }) mockToken('secure-store') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).not.toContain('Authenticated accounts') @@ -348,7 +346,7 @@ describe('config view', () => { }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json']) const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(parsed.users[0].token).toBe('****…_123') @@ -361,7 +359,7 @@ describe('config view', () => { presentConfig({ users: [{ ...STORED_ALAN, token: 'tw_userA_plaintext_fallback_123' }] }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view', '--json', '--show-token']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json', '--show-token']) const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(parsed.users[0].token).toBe('tw_userA_plaintext_fallback_123') @@ -373,7 +371,7 @@ describe('config view', () => { mockProbeApiToken.mockRejectedValue(new NoTokenError()) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'tw', 'config', 'view']) + await createProgram().parseAsync(['node', 'cm', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('User settings') @@ -395,7 +393,7 @@ describe('config set', () => { await createProgram().parseAsync([ 'node', - 'tw', + 'cm', 'config', 'set', 'unarchive-new-threads', @@ -419,7 +417,7 @@ describe('config set', () => { await createProgram().parseAsync([ 'node', - 'tw', + 'cm', 'config', 'set', 'unarchive-new-threads', @@ -444,7 +442,7 @@ describe('config set', () => { await createProgram().parseAsync([ 'node', - 'tw', + 'cm', 'config', 'set', 'unarchive-new-threads', @@ -462,7 +460,7 @@ describe('config set', () => { mockReadConfigStrict.mockResolvedValue({ state: 'present', config: {} }) await expect( - createProgram().parseAsync(['node', 'tw', 'config', 'set', 'nope', 'true']), + createProgram().parseAsync(['node', 'cm', 'config', 'set', 'nope', 'true']), ).rejects.toBeInstanceOf(CliError) expect(mockSetConfig).not.toHaveBeenCalled() }) @@ -473,7 +471,7 @@ describe('config set', () => { await expect( createProgram().parseAsync([ 'node', - 'tw', + 'cm', 'config', 'set', 'unarchive-new-threads', @@ -489,7 +487,7 @@ describe('config set', () => { await createProgram().parseAsync([ 'node', - 'tw', + 'cm', 'config', 'set', 'unarchive-new-threads', @@ -508,7 +506,7 @@ describe('config set', () => { await expect( createProgram().parseAsync([ 'node', - 'tw', + 'cm', 'config', 'set', 'unarchive-new-threads', diff --git a/src/commands/config/index.ts b/src/commands/config/index.ts index 0112960..5c8a0d3 100644 --- a/src/commands/config/index.ts +++ b/src/commands/config/index.ts @@ -22,8 +22,8 @@ Settable keys: ${listSettableKeys()} Examples: - $ tw config set unarchive-new-threads true - $ tw config set unarchive-new-threads false`, + $ cm config set unarchive-new-threads true + $ cm config set unarchive-new-threads false`, ) .action(setConfigValue) @@ -31,9 +31,9 @@ Examples: 'after', ` Examples: - $ tw config view # pretty-printed, token masked - $ tw config view --json # raw JSON, token masked - $ tw config view --show-token # include the full token - $ tw config set unarchive-new-threads true # change a user preference`, + $ cm config view # pretty-printed, token masked + $ cm config view --json # raw JSON, token masked + $ cm config view --show-token # include the full token + $ cm config set unarchive-new-threads true # change a user preference`, ) } diff --git a/src/commands/config/view.ts b/src/commands/config/view.ts index af10be9..8179e97 100644 --- a/src/commands/config/view.ts +++ b/src/commands/config/view.ts @@ -97,15 +97,17 @@ function formatConfigView( // When a token is present, its metadata is the ground truth for the active // mode/scope — this matters most for env-sourced tokens, whose scope the CLI - // does not know and where config.auth* may be stale from an unrelated - // `tw auth login`. For missing/unavailable tokens, fall back to the config - // file values (what the CLI would attempt once auth recovers). - const effectiveMode = token.state === 'present' ? token.metadata.authMode : config.authMode - const effectiveScope = token.state === 'present' ? token.metadata.authScope : config.authScope + // does not know. For missing/unavailable tokens, fall back to the default + // user record (what the CLI would attempt once auth recovers). + const defaultRecord = getDefaultUserRecord(config) + const effectiveMode = + token.state === 'present' ? token.metadata.authMode : defaultRecord?.account.authMode + const effectiveScope = + token.state === 'present' ? token.metadata.authScope : defaultRecord?.account.authScope // When the mode is 'unknown' and no scope is recorded, the scope is // genuinely unintrospectable (env-sourced tokens, or tokens saved via - // `tw auth token` without metadata). Render 'unknown' rather than + // `cm auth token` without metadata). Render 'unknown' rather than // 'not set' to avoid reading as an explicit empty scope. const scopeDisplay = effectiveMode === 'unknown' && effectiveScope === undefined @@ -138,9 +140,6 @@ export async function viewConfig(options: ViewConfigOptions): Promise { if (options.json) { const output: Config = { ...config } - if (output.token && !options.showToken) { - output.token = maskToken(output.token) - } if (output.users && !options.showToken) { output.users = output.users.map((user) => user.token ? { ...user, token: maskToken(user.token) } : user, diff --git a/src/commands/conversation/conversation.test.ts b/src/commands/conversation/conversation.test.ts index 70778a3..c9a4df2 100644 --- a/src/commands/conversation/conversation.test.ts +++ b/src/commands/conversation/conversation.test.ts @@ -1,5 +1,5 @@ import { describeEmptyMachineOutput } from '@doist/cli-core/testing' -import type { BatchResponse as TwistBatchResponse } from '@doist/comms-sdk' +import type { BatchResponse as CommsBatchResponse } from '@doist/comms-sdk' import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' import { CliError } from '../../lib/errors.js' @@ -67,7 +67,7 @@ function createConversation(id: number, userIds: number[], lastActive: string): } } -type BatchResult = Pick, 'code' | 'data'> +type BatchResult = Pick, 'code' | 'data'> function createClient({ activeConversations = [], @@ -204,11 +204,11 @@ describe('conversation implicit view', () => { apiMocks.getCommsClient.mockRejectedValue(new Error('MOCK_API_REACHED')) }) - it('tw conversation routes to view (not unknown command)', async () => { + it('cm conversation routes to view (not unknown command)', async () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await expect(program.parseAsync(['node', 'tw', 'conversation', '100'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'cm', 'conversation', '100'])).rejects.toThrow( 'MOCK_API_REACHED', ) @@ -227,7 +227,7 @@ describe('conversation unread --workspace conflict', () => { await expect( program.parseAsync([ 'node', - 'tw', + 'cm', 'conversation', 'unread', 'Doist', @@ -247,7 +247,7 @@ describeEmptyMachineOutput('conversation unread empty output', { }, run: async (extraArgs) => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'conversation', 'unread', ...extraArgs]) + await program.parseAsync(['node', 'cm', 'conversation', 'unread', ...extraArgs]) }, humanMessage: 'No unread conversations.', }) @@ -275,7 +275,7 @@ describe('conversation with', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alice']) + await program.parseAsync(['node', 'cm', 'conversation', 'with', 'Alice']) expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('Alice', 1) expect(refsMocks.resolveConversationId).not.toHaveBeenCalled() @@ -313,7 +313,7 @@ describe('conversation with', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alice']) + await program.parseAsync(['node', 'cm', 'conversation', 'with', 'Alice']) expect(client.conversations.getConversations).toHaveBeenCalledWith({ workspaceId: 1, @@ -346,7 +346,7 @@ describe('conversation with', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alice']) + await program.parseAsync(['node', 'cm', 'conversation', 'with', 'Alice']) expect(client.conversations.getConversations).toHaveBeenNthCalledWith(1, { workspaceId: 1, @@ -389,7 +389,7 @@ describe('conversation with', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'conversation', 'with', 'Alice', @@ -420,7 +420,7 @@ describe('conversation with', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Me']) + await program.parseAsync(['node', 'cm', 'conversation', 'with', 'Me']) expect(consoleSpy).toHaveBeenCalledWith('Conversation with Me') @@ -441,7 +441,7 @@ describe('conversation with', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alice', '--json']) + await program.parseAsync(['node', 'cm', 'conversation', 'with', 'Alice', '--json']) expect(consoleSpy).toHaveBeenCalledTimes(1) expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual([]) @@ -460,7 +460,7 @@ describe('conversation with', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'conversation', 'with', 'Alex']), + program.parseAsync(['node', 'cm', 'conversation', 'with', 'Alex']), ).rejects.toHaveProperty('code', 'AMBIGUOUS_USER') }) }) @@ -498,7 +498,7 @@ describe('conversation view machine output', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'view', '42', '--json']) + await program.parseAsync(['node', 'cm', 'conversation', 'view', '42', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.conversation).toEqual({ @@ -523,7 +523,7 @@ describe('conversation view machine output', () => { consoleSpy.mockClear() - await program.parseAsync(['node', 'tw', 'conversation', 'view', '42', '--ndjson']) + await program.parseAsync(['node', 'cm', 'conversation', 'view', '42', '--ndjson']) expect(consoleSpy.mock.calls.map((call) => JSON.parse(call[0]))).toEqual([ { @@ -549,7 +549,7 @@ describe('conversation view machine output', () => { consoleSpy.mockClear() - await program.parseAsync(['node', 'tw', 'conversation', 'view', '42', '--json', '--full']) + await program.parseAsync(['node', 'cm', 'conversation', 'view', '42', '--json', '--full']) const fullJsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(fullJsonOutput.conversation.participantNames).toEqual(['Me', 'Alice Example']) @@ -600,7 +600,7 @@ describe('conversation view with failed batch response', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'conversation', 'view', '42']), + program.parseAsync(['node', 'cm', 'conversation', 'view', '42']), ).rejects.toThrow('Failed to fetch user 2: User lookup failed') }) }) @@ -618,7 +618,7 @@ describe('conversation mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'mute', '42']) + await program.parseAsync(['node', 'cm', 'conversation', 'mute', '42']) expect(client.conversations.muteConversation).toHaveBeenCalledWith({ id: 42, minutes: 60 }) expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 muted for 60 minutes.') @@ -634,7 +634,7 @@ describe('conversation mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'mute', '42', '--minutes', '480']) + await program.parseAsync(['node', 'cm', 'conversation', 'mute', '42', '--minutes', '480']) expect(client.conversations.muteConversation).toHaveBeenCalledWith({ id: 42, @@ -653,7 +653,7 @@ describe('conversation mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'mute', '42', '--dry-run']) + await program.parseAsync(['node', 'cm', 'conversation', 'mute', '42', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would mute conversation')) expect(consoleSpy).toHaveBeenCalledWith(' Conversation: conversation 42') @@ -673,7 +673,7 @@ describe('conversation mute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'conversation', 'mute', '42', '--dry-run']), + program.parseAsync(['node', 'cm', 'conversation', 'mute', '42', '--dry-run']), ).rejects.toThrow('conversation not found') expect(client.conversations.muteConversation).not.toHaveBeenCalled() }) @@ -682,7 +682,7 @@ describe('conversation mute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'conversation', 'mute', '42', '--minutes', 'foo']), + program.parseAsync(['node', 'cm', 'conversation', 'mute', '42', '--minutes', 'foo']), ).rejects.toHaveProperty('code', 'INVALID_MINUTES') }) }) @@ -700,7 +700,7 @@ describe('conversation unmute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'unmute', '42']) + await program.parseAsync(['node', 'cm', 'conversation', 'unmute', '42']) expect(client.conversations.unmuteConversation).toHaveBeenCalledWith(42) expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 unmuted.') @@ -716,7 +716,7 @@ describe('conversation unmute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'unmute', '42', '--dry-run']) + await program.parseAsync(['node', 'cm', 'conversation', 'unmute', '42', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Would unmute conversation'), @@ -737,7 +737,7 @@ describe('conversation unmute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'conversation', 'unmute', '42', '--dry-run']), + program.parseAsync(['node', 'cm', 'conversation', 'unmute', '42', '--dry-run']), ).rejects.toThrow('conversation not found') expect(client.conversations.unmuteConversation).not.toHaveBeenCalled() }) @@ -756,7 +756,7 @@ describe('conversation done', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'done', '42']) + await program.parseAsync(['node', 'cm', 'conversation', 'done', '42']) expect(client.conversations.archiveConversation).toHaveBeenCalledWith(42) expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 archived.') @@ -772,7 +772,7 @@ describe('conversation done', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'conversation', 'done', '42', '--dry-run']) + await program.parseAsync(['node', 'cm', 'conversation', 'done', '42', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Would archive conversation'), @@ -793,7 +793,7 @@ describe('conversation done', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'conversation', 'done', '42', '--dry-run']), + program.parseAsync(['node', 'cm', 'conversation', 'done', '42', '--dry-run']), ).rejects.toThrow('conversation not found') expect(client.conversations.archiveConversation).not.toHaveBeenCalled() }) diff --git a/src/commands/conversation/index.ts b/src/commands/conversation/index.ts index 62510cd..6e5da99 100644 --- a/src/commands/conversation/index.ts +++ b/src/commands/conversation/index.ts @@ -24,8 +24,8 @@ export function registerConversationCommand(program: Command): void { 'after', ` Examples: - tw conversation unread - tw conversation unread --json`, + cm conversation unread + cm conversation unread --json`, ) .action(showUnread) @@ -43,9 +43,9 @@ Examples: 'after', ` Examples: - tw conversation 12345 - tw conversation view 12345 --limit 20 - tw conversation view 12345 --since 2025-01-01 --json`, + cm conversation 12345 + cm conversation view 12345 --limit 20 + cm conversation view 12345 --since 2025-01-01 --json`, ) .action((ref, options) => { if (!ref) { @@ -68,9 +68,9 @@ Examples: 'after', ` Examples: - tw conversation with "Jane Smith" - tw conversation with id:5678 --json - tw conversation with "Jane" --include-groups --snippet`, + cm conversation with "Jane Smith" + cm conversation with id:5678 --json + cm conversation with "Jane" --include-groups --snippet`, ) .action(findConversationWithUser) @@ -84,9 +84,9 @@ Examples: 'after', ` Examples: - tw conversation reply 12345 "Hello!" - echo "Message body" | tw conversation reply 12345 - tw conversation reply 12345 "Update" --json`, + cm conversation reply 12345 "Hello!" + echo "Message body" | cm conversation reply 12345 + cm conversation reply 12345 "Update" --json`, ) .action(replyToConversation) @@ -99,8 +99,8 @@ Examples: 'after', ` Examples: - tw conversation done 12345 - tw conversation done 12345 --dry-run`, + cm conversation done 12345 + cm conversation done 12345 --dry-run`, ) .action(markConversationDone) @@ -115,8 +115,8 @@ Examples: 'after', ` Examples: - tw conversation mute 12345 - tw conversation mute 12345 --minutes 480`, + cm conversation mute 12345 + cm conversation mute 12345 --minutes 480`, ) .action(muteConversation) @@ -130,7 +130,7 @@ Examples: 'after', ` Examples: - tw conversation unmute 12345`, + cm conversation unmute 12345`, ) .action(unmuteConversation) } diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 823592f..46e3a00 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -44,7 +44,7 @@ import { getConfig } from '../lib/config.js' import { registerDoctorCommand } from './doctor.js' const mockReadFile = vi.mocked(readFile) -const mockCreateWrappedTwistClient = vi.mocked(createWrappedCommsClient) +const mockCreateWrappedCommsClient = vi.mocked(createWrappedCommsClient) const mockProbeApiToken = vi.mocked(probeApiToken) const mockGetConfig = vi.mocked(getConfig) @@ -81,7 +81,7 @@ describe('doctor command', () => { token: 'test_token_123456789', metadata: { authMode: 'read-write', source: 'secure-store' }, }) - mockCreateWrappedTwistClient.mockReturnValue({ + mockCreateWrappedCommsClient.mockReturnValue({ users: { getSessionUser: vi.fn().mockResolvedValue({ id: 1, @@ -110,7 +110,7 @@ describe('doctor command', () => { mockFetch('1.0.0') const program = createProgram() - await program.parseAsync(['node', 'tw', 'doctor']) + await program.parseAsync(['node', 'cm', 'doctor']) expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Node.js v20.19.0')) expect(consoleSpy).toHaveBeenCalledWith( @@ -126,7 +126,8 @@ describe('doctor command', () => { it('warns when plaintext config fallback is in use and an update is available', async () => { mockReadFile.mockResolvedValue( JSON.stringify({ - token: 'plaintext-token', + users: [{ id: '1', name: 'Person', token: 'plaintext-token' }], + defaultUserId: '1', update_channel: 'pre-release', }), ) @@ -138,7 +139,7 @@ describe('doctor command', () => { mockFetch('2.0.0') const program = createProgram() - await program.parseAsync(['node', 'tw', 'doctor']) + await program.parseAsync(['node', 'cm', 'doctor']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -158,9 +159,7 @@ describe('doctor command', () => { it('warns when config fields are invalid or unrecognized', async () => { mockReadFile.mockResolvedValue( JSON.stringify({ - pendingSecureStoreClear: 'yes', currentWorkspace: 'abc', - authMode: 'admin', update_channel: 'beta', extraSetting: true, }), @@ -168,7 +167,7 @@ describe('doctor command', () => { mockFetch('1.0.0') const program = createProgram() - await program.parseAsync(['node', 'tw', 'doctor']) + await program.parseAsync(['node', 'cm', 'doctor']) const configWarning = consoleSpy.mock.calls.find( (call: unknown[]) => @@ -181,9 +180,7 @@ describe('doctor command', () => { expect.stringContaining('WARN Config file is readable but'), ) expect(configWarning).toContain('contains unrecognized key "extraSetting"') - expect(configWarning).toContain('pendingSecureStoreClear must be a boolean') expect(configWarning).toContain('currentWorkspace must be a positive integer') - expect(configWarning).toContain('authMode must be one of: read-only, read-write, unknown') expect(configWarning).toContain('update_channel must be one of: stable, pre-release') expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('PASS Authenticated as person@example.com via secure-store'), @@ -195,63 +192,12 @@ describe('doctor command', () => { expect(process.exitCode).toBeUndefined() }) - it('reads legacy updateChannel from on-disk config and reports the channel', async () => { - // Disk still has the legacy camelCase key; read-seam translates it, - // so doctor reports the configured channel exactly as it would for - // a canonical `update_channel` file. No "unrecognized key" warning. - mockReadFile.mockResolvedValue( - JSON.stringify({ - token: 'plaintext-token', - updateChannel: 'pre-release', - }), - ) - mockGetConfig.mockResolvedValue({ updateChannel: 'pre-release' }) - mockProbeApiToken.mockResolvedValue({ - token: 'plaintext-token', - metadata: { authMode: 'read-write', source: 'config-file' }, - }) - mockFetch('1.0.0') - - const program = createProgram() - await program.parseAsync(['node', 'tw', 'doctor']) - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('PASS CLI is up to date on pre-release (v1.0.0)'), - ) - const configWarning = consoleSpy.mock.calls.find( - (call: unknown[]) => - typeof call[0] === 'string' && - (call[0] as string).includes('WARN Config file is readable but'), - )?.[0] - // updateChannel is a known legacy key — must not show as unrecognized. - expect(configWarning ?? '').not.toContain('unrecognized key "updateChannel"') - }) - - it('flags invalid legacy updateChannel value in the validator', async () => { - mockReadFile.mockResolvedValue( - JSON.stringify({ - updateChannel: 'beta', - }), - ) - mockFetch('1.0.0') - - const program = createProgram() - await program.parseAsync(['node', 'tw', 'doctor']) - - const configWarning = consoleSpy.mock.calls.find( - (call: unknown[]) => - typeof call[0] === 'string' && - (call[0] as string).includes('WARN Config file is readable but'), - )?.[0] - expect(configWarning).toContain('updateChannel must be one of: stable, pre-release') - }) - it('normalizes invalid update channel values to stable', async () => { mockGetConfig.mockResolvedValue({ updateChannel: 'beta' as never }) mockFetch('1.0.0') const program = createProgram() - await program.parseAsync(['node', 'tw', 'doctor']) + await program.parseAsync(['node', 'cm', 'doctor']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('PASS CLI is up to date on stable (v1.0.0)'), @@ -263,7 +209,7 @@ describe('doctor command', () => { mockProbeApiToken.mockRejectedValue(new NoTokenError()) const program = createProgram() - await program.parseAsync(['node', 'tw', 'doctor', '--json', '--offline']) + await program.parseAsync(['node', 'cm', 'doctor', '--json', '--offline']) const output = consoleSpy.mock.calls.at(-1)?.[0] expect(typeof output).toBe('string') @@ -287,7 +233,7 @@ describe('doctor command', () => { it('marks secure-store auth as skipped in offline mode', async () => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'doctor', '--offline']) + await program.parseAsync(['node', 'cm', 'doctor', '--offline']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -299,9 +245,9 @@ describe('doctor command', () => { it('does not instantiate the API client in offline mode', async () => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'doctor', '--offline']) + await program.parseAsync(['node', 'cm', 'doctor', '--offline']) - expect(mockCreateWrappedTwistClient).not.toHaveBeenCalled() + expect(mockCreateWrappedCommsClient).not.toHaveBeenCalled() }) it('fails when node or config are invalid', async () => { @@ -313,7 +259,7 @@ describe('doctor command', () => { mockFetch('1.0.0') const program = createProgram() - await program.parseAsync(['node', 'tw', 'doctor']) + await program.parseAsync(['node', 'cm', 'doctor']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('FAIL Node.js v18.0.0 does not satisfy ^20.19.0 || >=22.12.0'), diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 76ea43a..4d1c016 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -253,7 +253,7 @@ async function checkAuthentication(offline: boolean): Promise { return { name: 'auth', status: 'warn', - message: `No Twist credentials found. Set ${TOKEN_ENV_VAR} or run \`tw auth login\``, + message: `No Comms credentials found. Set ${TOKEN_ENV_VAR} or run \`cm auth login\``, } } @@ -397,6 +397,6 @@ export function registerDoctorCommand(program: Command): void { .command('doctor') .description('Diagnose common CLI setup and environment issues') .option('--json', 'Output diagnostic results as JSON') - .option('--offline', 'Skip network checks against Twist and npm') + .option('--offline', 'Skip network checks against Comms and npm') .action(doctorAction) } diff --git a/src/commands/groups/groups.test.ts b/src/commands/groups/groups.test.ts index 3e0e780..39e8d91 100644 --- a/src/commands/groups/groups.test.ts +++ b/src/commands/groups/groups.test.ts @@ -72,7 +72,7 @@ beforeEach(() => { }) }) -describeEmptyMachineOutput('tw groups list empty output', { +describeEmptyMachineOutput('cm groups list empty output', { setup: () => { vi.clearAllMocks() apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) @@ -80,17 +80,17 @@ describeEmptyMachineOutput('tw groups list empty output', { }, run: async (extraArgs) => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'groups', ...extraArgs]) + await program.parseAsync(['node', 'cm', 'groups', ...extraArgs]) }, humanMessage: 'No groups found.', }) -describe('tw groups list (default)', () => { +describe('cm groups list (default)', () => { it('lists all groups', async () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups']) + await program.parseAsync(['node', 'cm', 'groups']) expect(consoleSpy).toHaveBeenCalledTimes(3) expect(consoleSpy.mock.calls[0][0]).toContain('Frontend') @@ -100,7 +100,7 @@ describe('tw groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', '--json']) + await program.parseAsync(['node', 'cm', 'groups', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output).toHaveLength(3) @@ -111,7 +111,7 @@ describe('tw groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', 'list']) + await program.parseAsync(['node', 'cm', 'groups', 'list']) expect(consoleSpy).toHaveBeenCalledTimes(3) }) @@ -120,7 +120,7 @@ describe('tw groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', '--search', 'front']) + await program.parseAsync(['node', 'cm', 'groups', '--search', 'front']) expect(consoleSpy).toHaveBeenCalledTimes(1) expect(consoleSpy.mock.calls[0][0]).toContain('Frontend') @@ -131,7 +131,7 @@ describe('tw groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups']) + await program.parseAsync(['node', 'cm', 'groups']) expect(consoleSpy).toHaveBeenCalledTimes(1) expect(consoleSpy.mock.calls[0][0]).toContain('No groups') @@ -141,7 +141,7 @@ describe('tw groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', '--ndjson']) + await program.parseAsync(['node', 'cm', 'groups', '--ndjson']) // NDJSON emits all lines via formatNdjson in a single console.log call expect(consoleSpy).toHaveBeenCalledTimes(1) @@ -154,7 +154,7 @@ describe('tw groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', '--json', '--full']) + await program.parseAsync(['node', 'cm', 'groups', '--json', '--full']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output[0]).toHaveProperty('description') @@ -166,14 +166,14 @@ describe('tw groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', 'list', '1']) + await program.parseAsync(['node', 'cm', 'groups', 'list', '1']) expect(refsMocks.resolveWorkspaceRef).toHaveBeenCalledWith('1') expect(consoleSpy).toHaveBeenCalled() }) }) -describe('tw groups view', () => { +describe('cm groups view', () => { const batchUserResponses = [ { code: 200, data: { id: 1, name: 'Alice', email: 'a@d.com' } }, { code: 200, data: { id: 2, name: 'Bob', email: 'b@d.com' } }, @@ -189,7 +189,7 @@ describe('tw groups view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', 'view', 'Frontend']) + await program.parseAsync(['node', 'cm', 'groups', 'view', 'Frontend']) expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith('Frontend', 1) // Should batch-fetch users, not load all workspace users @@ -204,7 +204,7 @@ describe('tw groups view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', 'view', 'id:100', '--json']) + await program.parseAsync(['node', 'cm', 'groups', 'view', 'id:100', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.id).toBe(100) @@ -220,7 +220,7 @@ describe('tw groups view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', 'view', 'id:100', '--json', '--full']) + await program.parseAsync(['node', 'cm', 'groups', 'view', 'id:100', '--json', '--full']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.id).toBe(100) @@ -230,7 +230,7 @@ describe('tw groups view', () => { }) }) -describe('tw groups create', () => { +describe('cm groups create', () => { beforeEach(() => { apiMocks.createGroup.mockResolvedValue({ ...frontend, id: 999, name: 'Design' }) }) @@ -239,7 +239,7 @@ describe('tw groups create', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', 'create', 'Design']) + await program.parseAsync(['node', 'cm', 'groups', 'create', 'Design']) expect(apiMocks.createGroup).toHaveBeenCalledWith({ workspaceId: 1, @@ -256,7 +256,7 @@ describe('tw groups create', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'groups', 'create', 'Design', @@ -275,12 +275,12 @@ describe('tw groups create', () => { it('rejects empty name', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'groups', 'create', ' ']), + program.parseAsync(['node', 'cm', 'groups', 'create', ' ']), ).rejects.toMatchObject({ code: 'INVALID_NAME' }) }) }) -describe('tw groups rename', () => { +describe('cm groups rename', () => { beforeEach(() => { refsMocks.resolveGroupRef.mockResolvedValue(frontend) apiMocks.updateGroup.mockResolvedValue({ ...frontend, name: 'FE Team' }) @@ -290,14 +290,14 @@ describe('tw groups rename', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', 'rename', 'Frontend', 'FE Team']) + await program.parseAsync(['node', 'cm', 'groups', 'rename', 'Frontend', 'FE Team']) expect(apiMocks.updateGroup).toHaveBeenCalledWith({ id: 100, name: 'FE Team' }) expect(consoleSpy.mock.calls[0][0]).toContain('FE Team') }) }) -describe('tw groups delete', () => { +describe('cm groups delete', () => { beforeEach(() => { refsMocks.resolveGroupRef.mockResolvedValue(frontend) }) @@ -306,7 +306,7 @@ describe('tw groups delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', 'delete', 'Frontend']) + await program.parseAsync(['node', 'cm', 'groups', 'delete', 'Frontend']) expect(apiMocks.deleteGroup).not.toHaveBeenCalled() expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('Use --yes'))).toBe(true) @@ -316,7 +316,7 @@ describe('tw groups delete', () => { const program = createProgram() vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', 'delete', 'Frontend', '--yes']) + await program.parseAsync(['node', 'cm', 'groups', 'delete', 'Frontend', '--yes']) expect(apiMocks.deleteGroup).toHaveBeenCalledWith(100) }) @@ -324,12 +324,12 @@ describe('tw groups delete', () => { it('errors in --json mode without --yes', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'groups', 'delete', 'Frontend', '--json']), + program.parseAsync(['node', 'cm', 'groups', 'delete', 'Frontend', '--json']), ).rejects.toMatchObject({ code: 'MISSING_YES_FLAG' }) }) }) -describe('tw groups add-user', () => { +describe('cm groups add-user', () => { beforeEach(() => { refsMocks.resolveGroupRef.mockResolvedValue({ ...frontend, userIds: [1, 2] }) }) @@ -341,7 +341,7 @@ describe('tw groups add-user', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'groups', 'add-user', 'Frontend', @@ -360,7 +360,7 @@ describe('tw groups add-user', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'groups', 'add-user', 'id:100', @@ -376,7 +376,7 @@ describe('tw groups add-user', () => { const program = createProgram() vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', 'add-user', 'Frontend', 'id:1,id:3']) + await program.parseAsync(['node', 'cm', 'groups', 'add-user', 'Frontend', 'id:1,id:3']) expect(apiMocks.addUsersToGroup).toHaveBeenCalledWith(100, [3]) }) @@ -386,7 +386,7 @@ describe('tw groups add-user', () => { const program = createProgram() vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'groups', 'add-user', 'Frontend', 'id:1,id:2']) + await program.parseAsync(['node', 'cm', 'groups', 'add-user', 'Frontend', 'id:1,id:2']) expect(apiMocks.addUsersToGroup).not.toHaveBeenCalled() }) @@ -394,7 +394,7 @@ describe('tw groups add-user', () => { it('errors when no user refs given', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'groups', 'add-user', 'Frontend']), + program.parseAsync(['node', 'cm', 'groups', 'add-user', 'Frontend']), ).rejects.toMatchObject({ code: 'MISSING_USERS' }) }) @@ -405,7 +405,7 @@ describe('tw groups add-user', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'groups', 'add-user', 'Frontend', @@ -418,7 +418,7 @@ describe('tw groups add-user', () => { }) }) -describe('tw groups remove-user', () => { +describe('cm groups remove-user', () => { beforeEach(() => { refsMocks.resolveGroupRef.mockResolvedValue({ ...frontend, userIds: [1, 2, 3] }) }) @@ -430,7 +430,7 @@ describe('tw groups remove-user', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'groups', 'remove-user', 'Frontend', @@ -447,7 +447,7 @@ describe('tw groups remove-user', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'groups', 'remove-user', 'Frontend', @@ -460,7 +460,7 @@ describe('tw groups remove-user', () => { it('errors when no user refs given', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'groups', 'remove-user', 'Frontend']), + program.parseAsync(['node', 'cm', 'groups', 'remove-user', 'Frontend']), ).rejects.toMatchObject({ code: 'MISSING_USERS' }) }) @@ -471,7 +471,7 @@ describe('tw groups remove-user', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'groups', 'remove-user', 'Frontend', diff --git a/src/commands/groups/index.ts b/src/commands/groups/index.ts index e81457d..effffa4 100644 --- a/src/commands/groups/index.ts +++ b/src/commands/groups/index.ts @@ -21,9 +21,9 @@ export function registerGroupsCommand(program: Command): void { 'after', ` Examples: - tw groups - tw groups list --search front - tw groups --workspace 123 --json`, + cm groups + cm groups list --search front + cm groups --workspace 123 --json`, ) .action(listGroups) @@ -37,9 +37,9 @@ Examples: 'after', ` Examples: - tw groups view 12345 - tw groups view "Frontend" - tw groups view id:12345 --json`, + cm groups view 12345 + cm groups view "Frontend" + cm groups view id:12345 --json`, ) .action(viewGroup) @@ -55,9 +55,9 @@ Examples: 'after', ` Examples: - tw groups create "Frontend" - tw groups create "Backend" --users alice@doist.com,bob@doist.com - tw groups create "Design" --users id:123,id:456 --json`, + cm groups create "Frontend" + cm groups create "Backend" --users alice@doist.com,bob@doist.com + cm groups create "Design" --users id:123,id:456 --json`, ) .action(createGroupCommand) @@ -71,8 +71,8 @@ Examples: 'after', ` Examples: - tw groups rename 12345 "Frontend Team" - tw groups rename "Frontend" "Frontend Team" --json`, + cm groups rename 12345 "Frontend Team" + cm groups rename "Frontend" "Frontend Team" --json`, ) .action(renameGroup) @@ -86,8 +86,8 @@ Examples: 'after', ` Examples: - tw groups delete 12345 --yes - tw groups delete "Frontend" --dry-run`, + cm groups delete 12345 --yes + cm groups delete "Frontend" --dry-run`, ) .action(deleteGroupCommand) @@ -101,9 +101,9 @@ Examples: 'after', ` Examples: - tw groups add-user 12345 alice@doist.com bob@doist.com - tw groups add-user "Frontend" id:123,id:456 - tw groups add-user 12345 alice bob carol --json + cm groups add-user 12345 alice@doist.com bob@doist.com + cm groups add-user "Frontend" id:123,id:456 + cm groups add-user 12345 alice bob carol --json User references can be passed as space-separated args, comma-separated within a single arg, or any mix of the two.`, @@ -120,8 +120,8 @@ single arg, or any mix of the two.`, 'after', ` Examples: - tw groups remove-user 12345 alice@doist.com - tw groups remove-user "Frontend" id:123,id:456`, + cm groups remove-user 12345 alice@doist.com + cm groups remove-user "Frontend" id:123,id:456`, ) .action(removeUsersCommand) } diff --git a/src/commands/inbox.test.ts b/src/commands/inbox.test.ts index 5356a5f..90548d3 100644 --- a/src/commands/inbox.test.ts +++ b/src/commands/inbox.test.ts @@ -45,7 +45,7 @@ describe('inbox --workspace conflict', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'inbox', 'Doist', '--workspace', 'Other']), + program.parseAsync(['node', 'cm', 'inbox', 'Doist', '--workspace', 'Other']), ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') }) }) @@ -73,7 +73,7 @@ describe('inbox --archive-filter', () => { it('passes archiveFilter to SDK getInbox', async () => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'inbox', '--archive-filter', 'all', '--json']) + await program.parseAsync(['node', 'cm', 'inbox', '--archive-filter', 'all', '--json']) expect(mockGetInbox).toHaveBeenCalledWith( expect.objectContaining({ archiveFilter: 'all' }), @@ -83,7 +83,7 @@ describe('inbox --archive-filter', () => { it('defaults archiveFilter to active when not provided', async () => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'inbox', '--json']) + await program.parseAsync(['node', 'cm', 'inbox', '--json']) expect(mockGetInbox).toHaveBeenCalledWith( expect.objectContaining({ archiveFilter: 'active' }), @@ -95,7 +95,7 @@ describe('inbox --archive-filter', () => { const program = createProgram() await program.parseAsync([ 'node', - 'tw', + 'cm', 'inbox', '--since', '2026-01-01', @@ -138,7 +138,7 @@ describeEmptyMachineOutput('inbox empty output', { }, run: async (extraArgs) => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'inbox', ...extraArgs]) + await program.parseAsync(['node', 'cm', 'inbox', ...extraArgs]) }, humanMessage: 'No threads in inbox.', }) @@ -174,7 +174,7 @@ describe('inbox empty output (channel filter)', () => { it('outputs [] for --json when --channel filter matches nothing', async () => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'inbox', '--channel', 'nonexistent', '--json']) + await program.parseAsync(['node', 'cm', 'inbox', '--channel', 'nonexistent', '--json']) expect(logSpy).toHaveBeenCalledTimes(1) expect(logSpy).toHaveBeenCalledWith('[]') @@ -209,7 +209,7 @@ describe('inbox batch errors', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'inbox', '--unread', '--limit', '1000']), + program.parseAsync(['node', 'cm', 'inbox', '--unread', '--limit', '1000']), ).rejects.toThrow('Failed to fetch inbox threads: limit must be less than or equal to 500') }) @@ -245,7 +245,7 @@ describe('inbox batch errors', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'tw', 'inbox', '--json']) + await program.parseAsync(['node', 'cm', 'inbox', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output).toHaveLength(1) diff --git a/src/commands/inbox.ts b/src/commands/inbox.ts index e50a908..d4bf54a 100644 --- a/src/commands/inbox.ts +++ b/src/commands/inbox.ts @@ -193,12 +193,12 @@ export function registerInboxCommand(program: Command): void { 'after', ` Examples: - tw inbox - tw inbox --unread - tw inbox --archive-filter all - tw inbox --archive-filter archived - tw inbox --channel engineering --since 2025-01-01 - tw inbox --limit 10 --json`, + cm inbox + cm inbox --unread + cm inbox --archive-filter all + cm inbox --archive-filter archived + cm inbox --channel engineering --since 2025-01-01 + cm inbox --limit 10 --json`, ) .action(showInbox) } diff --git a/src/commands/mentions.test.ts b/src/commands/mentions.test.ts index 329f7e7..e8f5f6d 100644 --- a/src/commands/mentions.test.ts +++ b/src/commands/mentions.test.ts @@ -55,7 +55,7 @@ describe('mentions', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'mentions', 'Doist', '--workspace', 'Other']), + program.parseAsync(['node', 'cm', 'mentions', 'Doist', '--workspace', 'Other']), ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') }) @@ -63,7 +63,7 @@ describe('mentions', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'mentions']) + await program.parseAsync(['node', 'cm', 'mentions']) expect(searchApiMocks.extendedSearch).toHaveBeenCalledWith( expect.objectContaining({ @@ -94,7 +94,7 @@ describe('mentions', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'mentions', '--all']) + await program.parseAsync(['node', 'cm', 'mentions', '--all']) expect(searchApiMocks.extendedSearch).toHaveBeenNthCalledWith( 1, @@ -118,7 +118,7 @@ describe('mentions', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'mentions', '--json']) + await program.parseAsync(['node', 'cm', 'mentions', '--json']) expect(logSpy).toHaveBeenCalledTimes(1) expect(JSON.parse(logSpy.mock.calls[0][0])).toEqual({ @@ -133,7 +133,7 @@ describe('mentions', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'mentions', '--ndjson']) + await program.parseAsync(['node', 'cm', 'mentions', '--ndjson']) expect(logSpy).toHaveBeenCalledTimes(1) expect(JSON.parse(logSpy.mock.calls[0][0])).toEqual({ diff --git a/src/commands/mentions.ts b/src/commands/mentions.ts index 4755978..2591c55 100644 --- a/src/commands/mentions.ts +++ b/src/commands/mentions.ts @@ -33,9 +33,9 @@ export function registerMentionsCommand(program: Command): void { 'after', ` Examples: - tw mentions - tw mentions --since 2026-04-01 --all - tw mentions --type threads --json`, + cm mentions + cm mentions --since 2026-04-01 --all + cm mentions --type threads --json`, ) .action(mentions) } diff --git a/src/commands/msg/index.ts b/src/commands/msg/index.ts index a0b31d1..61b9bab 100644 --- a/src/commands/msg/index.ts +++ b/src/commands/msg/index.ts @@ -19,8 +19,8 @@ export function registerMsgCommand(program: Command): void { 'after', ` Examples: - tw msg 12345 - tw msg view 12345 --json`, + cm msg 12345 + cm msg view 12345 --json`, ) .action((ref, options) => { if (!ref) { @@ -39,9 +39,9 @@ Examples: 'after', ` Examples: - tw msg update 12345 "Updated text" - echo "New content" | tw msg update 12345 - tw msg update 12345 "Fixed typo" --json`, + cm msg update 12345 "Updated text" + echo "New content" | cm msg update 12345 + cm msg update 12345 "Fixed typo" --json`, ) .action(updateMessage) @@ -53,8 +53,8 @@ Examples: 'after', ` Examples: - tw msg delete 12345 - tw msg delete 12345 --dry-run`, + cm msg delete 12345 + cm msg delete 12345 --dry-run`, ) .action(deleteMessage) } diff --git a/src/commands/msg/msg.test.ts b/src/commands/msg/msg.test.ts index 93f0f9a..706eba5 100644 --- a/src/commands/msg/msg.test.ts +++ b/src/commands/msg/msg.test.ts @@ -85,11 +85,11 @@ describe('msg implicit view', () => { apiMocks.getCommsClient.mockRejectedValue(new Error('MOCK_API_REACHED')) }) - it('tw msg routes to view (not unknown command)', async () => { + it('cm msg routes to view (not unknown command)', async () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await expect(program.parseAsync(['node', 'tw', 'msg', '200'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'cm', 'msg', '200'])).rejects.toThrow( 'MOCK_API_REACHED', ) @@ -108,7 +108,7 @@ describe('msg delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'msg', 'delete', '200']) + await program.parseAsync(['node', 'cm', 'msg', 'delete', '200']) expect(client.conversationMessages.deleteMessage).toHaveBeenCalledWith(200) expect(consoleSpy).toHaveBeenCalledWith('Message 200 deleted.') @@ -121,7 +121,7 @@ describe('msg delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'msg', 'delete', '200', '--dry-run']) + await program.parseAsync(['node', 'cm', 'msg', 'delete', '200', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would delete message')) expect(consoleSpy).toHaveBeenCalledWith(' Message: 200') @@ -136,7 +136,7 @@ describe('msg delete', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'msg', 'delete', '200', '--dry-run']), + program.parseAsync(['node', 'cm', 'msg', 'delete', '200', '--dry-run']), ).rejects.toHaveProperty('code', 'NOT_CREATOR') expect(client.conversationMessages.deleteMessage).not.toHaveBeenCalled() }) @@ -147,7 +147,7 @@ describe('msg delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'msg', 'delete', '200', '--json']) + await program.parseAsync(['node', 'cm', 'msg', 'delete', '200', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual({ id: 200, deleted: true }) diff --git a/src/commands/react.test.ts b/src/commands/react.test.ts index d8eeea8..cbd5008 100644 --- a/src/commands/react.test.ts +++ b/src/commands/react.test.ts @@ -39,7 +39,7 @@ describe('react refs', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'react', 'thread', 'https://comms.todoist.com/a/1/ch/2/t/99', @@ -56,7 +56,7 @@ describe('react refs', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'unreact', 'message', 'https://comms.todoist.com/a/1/msg/33/m/44', @@ -71,7 +71,7 @@ describe('react refs', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'react', 'thread', '99', '+1', '--json']) + await program.parseAsync(['node', 'cm', 'react', 'thread', '99', '+1', '--json']) expect(apiMocks.addReaction).toHaveBeenCalledWith({ threadId: 99, reaction: '👍' }) const output = JSON.parse(logSpy.mock.calls[0][0]) @@ -88,7 +88,7 @@ describe('react refs', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'unreact', 'comment', '42', 'heart', '--json']) + await program.parseAsync(['node', 'cm', 'unreact', 'comment', '42', 'heart', '--json']) expect(apiMocks.removeReaction).toHaveBeenCalledWith({ commentId: 42, reaction: '❤️' }) const output = JSON.parse(logSpy.mock.calls[0][0]) @@ -107,7 +107,7 @@ describe('react refs', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'react', 'message', '77', diff --git a/src/commands/react.ts b/src/commands/react.ts index 40358dc..ef19dce 100644 --- a/src/commands/react.ts +++ b/src/commands/react.ts @@ -159,10 +159,10 @@ export function registerReactCommand(program: Command): void { 'after', ` Examples: - tw react thread 12345 +1 - tw react comment 67890 heart - tw react message 11111 tada --dry-run - tw react thread 12345 +1 --json`, + cm react thread 12345 +1 + cm react comment 67890 heart + cm react message 11111 tada --dry-run + cm react thread 12345 +1 --json`, ) .action((targetType: string, targetRef: string, emoji: string, options: ReactOptions) => { if (!['thread', 'comment', 'message'].includes(targetType)) { @@ -183,9 +183,9 @@ Examples: 'after', ` Examples: - tw unreact thread 12345 +1 - tw unreact comment 67890 heart - tw unreact thread 12345 +1 --json`, + cm unreact thread 12345 +1 + cm unreact comment 67890 heart + cm unreact thread 12345 +1 --json`, ) .action((targetType: string, targetRef: string, emoji: string, options: ReactOptions) => { if (!['thread', 'comment', 'message'].includes(targetType)) { diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index a86c919..1d801c2 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -55,7 +55,7 @@ describe('search --workspace conflict', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'search', 'query', 'Doist', '--workspace', 'Other']), + program.parseAsync(['node', 'cm', 'search', 'query', 'Doist', '--workspace', 'Other']), ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') }) @@ -75,7 +75,7 @@ describe('search --workspace conflict', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await program.parseAsync([ 'node', - 'tw', + 'cm', 'search', 'query', '--channel', @@ -111,7 +111,7 @@ describe('search --workspace conflict', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'search', 'query', '--all']) + await program.parseAsync(['node', 'cm', 'search', 'query', '--all']) expect(searchApiMocks.extendedSearch).toHaveBeenNthCalledWith( 1, diff --git a/src/commands/search.ts b/src/commands/search.ts index 5627037..5612da3 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -44,10 +44,10 @@ export function registerSearchCommand(program: Command): void { 'after', ` Examples: - tw search "deployment issue" - tw search "bug report" --type threads --channel id:12345 - tw search "API" --author id:5678 --since 2025-01-01 --json - tw search "incident" --all --json`, + cm search "deployment issue" + cm search "bug report" --type threads --channel id:12345 + cm search "API" --author id:5678 --since 2025-01-01 --json + cm search "incident" --all --json`, ) .action(search) } diff --git a/src/commands/skill/install.ts b/src/commands/skill/install.ts index 7965ebf..76cc43c 100644 --- a/src/commands/skill/install.ts +++ b/src/commands/skill/install.ts @@ -8,7 +8,7 @@ export async function install(agentName: string, options: InstallOptions): Promi if (!installer) { throw new CliError('UNKNOWN_AGENT', `Unknown agent: ${agentName}`, [ - 'Run `tw skill list` to see available agents', + 'Run `cm skill list` to see available agents', ]) } diff --git a/src/commands/skill/skill.test.ts b/src/commands/skill/skill.test.ts index 02aece6..6a2b484 100644 --- a/src/commands/skill/skill.test.ts +++ b/src/commands/skill/skill.test.ts @@ -130,7 +130,7 @@ describe('installer operations', () => { const content = await readFile(skillPath, 'utf-8') expect(content).toBe(SKILL_FILE_CONTENT) expect(content).toContain('name: comms-cli') - expect(content).toContain('description: "Twist messaging CLI.') + expect(content).toContain('description: "Comms messaging CLI.') expect(content).toContain('license: MIT') expect(content).toContain('author: Doist') expect(content).toContain(`version: "${SKILL_VERSION}"`) @@ -253,13 +253,13 @@ describe('skill command', () => { it('lists agents', async () => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'skill', 'list', '--local']) + await program.parseAsync(['node', 'cm', 'skill', 'list', '--local']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Available agents')) }) it('installs agent locally', async () => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'skill', 'install', 'claude-code', '--local']) + await program.parseAsync(['node', 'cm', 'skill', 'install', 'claude-code', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Installed')) const skillPath = join(testDir, '.claude', 'skills', 'comms-cli', 'SKILL.md') @@ -271,7 +271,7 @@ describe('skill command', () => { it('installs codex agent locally', async () => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'skill', 'install', 'codex', '--local']) + await program.parseAsync(['node', 'cm', 'skill', 'install', 'codex', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Installed')) const skillPath = join(testDir, '.codex', 'skills', 'comms-cli', 'SKILL.md') @@ -281,7 +281,7 @@ describe('skill command', () => { it('installs cursor agent locally', async () => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'skill', 'install', 'cursor', '--local']) + await program.parseAsync(['node', 'cm', 'skill', 'install', 'cursor', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Installed')) const skillPath = join(testDir, '.cursor', 'skills', 'comms-cli', 'SKILL.md') @@ -294,7 +294,7 @@ describe('skill command', () => { await installer.install({ local: true }) const program = createProgram() - await program.parseAsync(['node', 'tw', 'skill', 'uninstall', 'claude-code', '--local']) + await program.parseAsync(['node', 'cm', 'skill', 'uninstall', 'claude-code', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Uninstalled')) }) @@ -302,7 +302,7 @@ describe('skill command', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'skill', 'install', 'unknown-agent', '--local']), + program.parseAsync(['node', 'cm', 'skill', 'install', 'unknown-agent', '--local']), ).rejects.toHaveProperty('code', 'UNKNOWN_AGENT') }) @@ -311,7 +311,7 @@ describe('skill command', () => { await installer.install({ local: true }) const program = createProgram() - await program.parseAsync(['node', 'tw', 'skill', 'update', 'claude-code', '--local']) + await program.parseAsync(['node', 'cm', 'skill', 'update', 'claude-code', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Updated')) }) @@ -319,7 +319,7 @@ describe('skill command', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'skill', 'update', 'unknown-agent', '--local']), + program.parseAsync(['node', 'cm', 'skill', 'update', 'unknown-agent', '--local']), ).rejects.toHaveProperty('code', 'UNKNOWN_AGENT') }) @@ -328,7 +328,7 @@ describe('skill command', () => { await skillInstallers.codex.install({ local: true }) const program = createProgram() - await program.parseAsync(['node', 'tw', 'skill', 'update', 'all', '--local']) + await program.parseAsync(['node', 'cm', 'skill', 'update', 'all', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Updated claude-code')) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Updated codex')) }) @@ -337,7 +337,7 @@ describe('skill command', () => { await skillInstallers.cursor.install({ local: true }) const program = createProgram() - await program.parseAsync(['node', 'tw', 'skill', 'update', '--local']) + await program.parseAsync(['node', 'cm', 'skill', 'update', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Updated cursor')) }) }) diff --git a/src/commands/skill/uninstall.ts b/src/commands/skill/uninstall.ts index abe72eb..db0e0f9 100644 --- a/src/commands/skill/uninstall.ts +++ b/src/commands/skill/uninstall.ts @@ -8,7 +8,7 @@ export async function uninstall(agentName: string, options: UninstallOptions): P if (!installer) { throw new CliError('UNKNOWN_AGENT', `Unknown agent: ${agentName}`, [ - 'Run `tw skill list` to see available agents', + 'Run `cm skill list` to see available agents', ]) } diff --git a/src/commands/skill/update.ts b/src/commands/skill/update.ts index 028daa5..6c8ce8f 100644 --- a/src/commands/skill/update.ts +++ b/src/commands/skill/update.ts @@ -33,7 +33,7 @@ export async function updateSkill(agentName: string, options: UpdateOptions): Pr if (!installer) { throw new CliError('UNKNOWN_AGENT', `Unknown agent: ${agentName}`, [ - 'Run `tw skill list` to see available agents', + 'Run `cm skill list` to see available agents', ]) } diff --git a/src/commands/thread/index.ts b/src/commands/thread/index.ts index f62ca48..c2a06b2 100644 --- a/src/commands/thread/index.ts +++ b/src/commands/thread/index.ts @@ -29,9 +29,9 @@ export function registerThreadCommand(program: Command): void { 'after', ` Examples: - tw thread 12345 - tw thread view 12345 --unread - tw thread view 12345 --limit 10 --json`, + cm thread 12345 + cm thread view 12345 --unread + cm thread view 12345 --limit 10 --json`, ) .action((ref, options) => { if (!ref) { @@ -62,9 +62,9 @@ Examples: 'after', ` Examples: - tw thread reply 12345 "Sounds good!" - echo "Long reply" | tw thread reply 12345 - tw thread reply 12345 "Done" --close --json`, + cm thread reply 12345 "Sounds good!" + echo "Long reply" | cm thread reply 12345 + cm thread reply 12345 "Done" --close --json`, ) .action(replyToThread) @@ -84,10 +84,10 @@ Examples: 'after', ` Examples: - tw thread create 12345 "Weekly update" "Here's what happened..." - echo "Body from stdin" | tw thread create id:12345 "Title" - tw thread create 12345 "Title" "Body" --notify 67890,11111 --json - tw thread create 12345 "Title" "Body" --unarchive`, + cm thread create 12345 "Weekly update" "Here's what happened..." + echo "Body from stdin" | cm thread create id:12345 "Title" + cm thread create 12345 "Title" "Body" --notify 67890,11111 --json + cm thread create 12345 "Title" "Body" --unarchive`, ) .action(createThread) @@ -100,9 +100,9 @@ Examples: 'after', ` Examples: - tw thread done 12345 - tw thread done 12345 --dry-run - tw thread done 12345 --json`, + cm thread done 12345 + cm thread done 12345 --dry-run + cm thread done 12345 --json`, ) .action(markThreadDone) @@ -116,9 +116,9 @@ Examples: 'after', ` Examples: - tw thread delete 12345 --yes - tw thread delete 12345 --dry-run - tw thread delete 12345 --yes --json`, + cm thread delete 12345 --yes + cm thread delete 12345 --dry-run + cm thread delete 12345 --yes --json`, ) .action(deleteThread) @@ -133,8 +133,8 @@ Examples: 'after', ` Examples: - tw thread mute 12345 - tw thread mute 12345 --minutes 480`, + cm thread mute 12345 + cm thread mute 12345 --minutes 480`, ) .action(muteThread) @@ -156,9 +156,9 @@ Examples: 'after', ` Examples: - tw thread update 12345 "Updated body text" - echo "New body" | tw thread update 12345 - tw thread update 12345 "Fixed" --json`, + cm thread update 12345 "Updated body text" + echo "New body" | cm thread update 12345 + cm thread update 12345 "Fixed" --json`, ) .action(updateThread) @@ -172,7 +172,7 @@ Examples: 'after', ` Examples: - tw thread unmute 12345`, + cm thread unmute 12345`, ) .action(unmuteThread) } diff --git a/src/commands/thread/thread.test.ts b/src/commands/thread/thread.test.ts index 62647ef..86bdf82 100644 --- a/src/commands/thread/thread.test.ts +++ b/src/commands/thread/thread.test.ts @@ -1,4 +1,4 @@ -import type { BatchResponse as TwistBatchResponse } from '@doist/comms-sdk' +import type { BatchResponse as CommsBatchResponse } from '@doist/comms-sdk' import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -74,7 +74,7 @@ function createComment(id: number, objIndex: number) { } } -type BatchResult = Pick, 'code' | 'data'> +type BatchResult = Pick, 'code' | 'data'> function createClient({ thread = createThreadFixture(500), @@ -208,13 +208,13 @@ describe('thread implicit view', () => { apiMocks.getCommsClient.mockRejectedValue(new Error('MOCK_API_REACHED')) }) - it('tw thread routes to view (not unknown command)', async () => { + it('cm thread routes to view (not unknown command)', async () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) // If Commander routes to view, it will call getCommsClient which throws MOCK_API_REACHED. // If it doesn't route, Commander throws "unknown command '100'". - await expect(program.parseAsync(['node', 'tw', 'thread', '100'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'cm', 'thread', '100'])).rejects.toThrow( 'MOCK_API_REACHED', ) @@ -231,7 +231,7 @@ describe('thread implicit view', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'reply', '100', @@ -257,7 +257,7 @@ describe('thread implicit view', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'reply', '100', @@ -282,7 +282,7 @@ describe('thread implicit view', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'reply', '100', @@ -305,7 +305,7 @@ describe('thread implicit view', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) vi.mocked(readStdin).mockResolvedValueOnce('closing comment') - await program.parseAsync(['node', 'tw', 'thread', 'reply', '500', '--close']) + await program.parseAsync(['node', 'cm', 'thread', 'reply', '500', '--close']) expect(client.threads.closeThread).toHaveBeenCalledWith( expect.objectContaining({ id: 500, content: 'closing comment' }), @@ -322,7 +322,7 @@ describe('thread implicit view', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) vi.mocked(readStdin).mockResolvedValueOnce('reopening comment') - await program.parseAsync(['node', 'tw', 'thread', 'reply', '500', '--reopen']) + await program.parseAsync(['node', 'cm', 'thread', 'reply', '500', '--reopen']) expect(client.threads.reopenThread).toHaveBeenCalledWith( expect.objectContaining({ id: 500, content: 'reopening comment' }), @@ -337,7 +337,7 @@ describe('thread implicit view', () => { await expect( program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'reply', '100', @@ -365,7 +365,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread']) + await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--unread']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Test Thread') @@ -386,7 +386,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread']) + await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--unread']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') // Should show original post @@ -413,7 +413,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread', '--json']) + await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--unread', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.thread.id).toBe(500) @@ -435,7 +435,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread', '--json']) + await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--unread', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.thread.id).toBe(500) @@ -455,7 +455,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--unread', '--ndjson']) + await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--unread', '--ndjson']) const lines = consoleSpy.mock.calls.map((c) => JSON.parse(c[0])) // First line is the thread @@ -480,7 +480,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--json']) + await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) // Without --unread, all comments are returned @@ -509,7 +509,7 @@ describe('thread view --since', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'view', '500', @@ -552,7 +552,7 @@ describe('thread view with failed batch response', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await expect( - program.parseAsync(['node', 'tw', 'thread', 'view', '500', '--comment', '99999']), + program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--comment', '99999']), ).rejects.toThrow('Failed to fetch comment 99999.') consoleSpy.mockRestore() @@ -570,7 +570,7 @@ describe('thread view with failed batch response', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await expect(program.parseAsync(['node', 'tw', 'thread', 'view', '500'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'cm', 'thread', 'view', '500'])).rejects.toThrow( 'Failed to fetch thread.', ) @@ -606,7 +606,7 @@ describe('thread view with failed user batch response', () => { const program = createProgram() - await expect(program.parseAsync(['node', 'tw', 'thread', 'view', '500'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'cm', 'thread', 'view', '500'])).rejects.toThrow( 'Failed to fetch user 2: User lookup failed', ) }) @@ -632,7 +632,7 @@ describe('thread view with failed user batch response', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'view', '500']) + await program.parseAsync(['node', 'cm', 'thread', 'view', '500']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Alice') @@ -657,7 +657,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'create', '100', @@ -686,7 +686,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'create', '100', @@ -711,7 +711,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'create', '100', @@ -735,7 +735,7 @@ describe('thread create', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'create', '100', 'My Title']) + await program.parseAsync(['node', 'cm', 'thread', 'create', '100', 'My Title']) expect(client.threads.createThread).toHaveBeenCalledWith( expect.objectContaining({ @@ -762,7 +762,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'create', '100', @@ -796,7 +796,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'create', '100', @@ -822,7 +822,7 @@ describe('thread create', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'thread', 'create', '100', 'My Title']), + program.parseAsync(['node', 'cm', 'thread', 'create', '100', 'My Title']), ).rejects.toHaveProperty('code', 'MISSING_CONTENT') }) @@ -833,7 +833,7 @@ describe('thread create', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'create', '100', 'T', 'body']) + await program.parseAsync(['node', 'cm', 'thread', 'create', '100', 'T', 'body']) expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() consoleSpy.mockRestore() @@ -848,7 +848,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'create', '100', @@ -871,7 +871,7 @@ describe('thread create', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'create', '100', 'T', 'body']) + await program.parseAsync(['node', 'cm', 'thread', 'create', '100', 'T', 'body']) expect(client.inbox.unarchiveThread).toHaveBeenCalledWith(999) consoleSpy.mockRestore() @@ -889,7 +889,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'create', '100', @@ -913,7 +913,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'create', '100', @@ -942,7 +942,7 @@ describe('thread mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'mute', '500']) + await program.parseAsync(['node', 'cm', 'thread', 'mute', '500']) expect(client.threads.muteThread).toHaveBeenCalledWith({ id: 500, minutes: 60 }) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 muted for 60 minutes.') @@ -957,7 +957,7 @@ describe('thread mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'mute', '500', '--minutes', '480']) + await program.parseAsync(['node', 'cm', 'thread', 'mute', '500', '--minutes', '480']) expect(client.threads.muteThread).toHaveBeenCalledWith({ id: 500, minutes: 480 }) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 muted for 480 minutes.') @@ -972,7 +972,7 @@ describe('thread mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'mute', '500', '--dry-run']) + await program.parseAsync(['node', 'cm', 'thread', 'mute', '500', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would mute thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') @@ -990,7 +990,7 @@ describe('thread mute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'thread', 'mute', '500', '--dry-run']), + program.parseAsync(['node', 'cm', 'thread', 'mute', '500', '--dry-run']), ).rejects.toThrow('thread not found') expect(client.threads.muteThread).not.toHaveBeenCalled() }) @@ -1002,7 +1002,7 @@ describe('thread mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'mute', '500', '--json']) + await program.parseAsync(['node', 'cm', 'thread', 'mute', '500', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(500) @@ -1016,7 +1016,7 @@ describe('thread mute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'thread', 'mute', '500', '--minutes', 'foo']), + program.parseAsync(['node', 'cm', 'thread', 'mute', '500', '--minutes', 'foo']), ).rejects.toHaveProperty('code', 'INVALID_MINUTES') }) }) @@ -1033,7 +1033,7 @@ describe('thread unmute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'unmute', '500']) + await program.parseAsync(['node', 'cm', 'thread', 'unmute', '500']) expect(client.threads.unmuteThread).toHaveBeenCalledWith(500) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 unmuted.') @@ -1048,7 +1048,7 @@ describe('thread unmute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'unmute', '500', '--dry-run']) + await program.parseAsync(['node', 'cm', 'thread', 'unmute', '500', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would unmute thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') @@ -1065,7 +1065,7 @@ describe('thread unmute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'thread', 'unmute', '500', '--dry-run']), + program.parseAsync(['node', 'cm', 'thread', 'unmute', '500', '--dry-run']), ).rejects.toThrow('thread not found') expect(client.threads.unmuteThread).not.toHaveBeenCalled() }) @@ -1082,7 +1082,7 @@ describe('thread delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'delete', '500', '--yes']) + await program.parseAsync(['node', 'cm', 'thread', 'delete', '500', '--yes']) expect(client.threads.deleteThread).toHaveBeenCalledWith(500) expect(consoleSpy).toHaveBeenCalledWith('Thread Test Thread (500) deleted.') @@ -1095,7 +1095,7 @@ describe('thread delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'delete', '500']) + await program.parseAsync(['node', 'cm', 'thread', 'delete', '500']) expect(consoleSpy).toHaveBeenCalledWith('Would delete: Test Thread') expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') @@ -1109,7 +1109,7 @@ describe('thread delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'delete', '500', '--dry-run']) + await program.parseAsync(['node', 'cm', 'thread', 'delete', '500', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would delete thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') @@ -1123,7 +1123,7 @@ describe('thread delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'delete', '500', '--json', '--yes']) + await program.parseAsync(['node', 'cm', 'thread', 'delete', '500', '--json', '--yes']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual({ id: 500, deleted: true }) @@ -1136,7 +1136,7 @@ describe('thread delete', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'thread', 'delete', '500', '--json']), + program.parseAsync(['node', 'cm', 'thread', 'delete', '500', '--json']), ).rejects.toHaveProperty('code', 'MISSING_YES_FLAG') expect(client.threads.deleteThread).not.toHaveBeenCalled() @@ -1148,7 +1148,7 @@ describe('thread delete', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'thread', 'delete', '500', '--yes']), + program.parseAsync(['node', 'cm', 'thread', 'delete', '500', '--yes']), ).rejects.toHaveProperty('code', 'NOT_CREATOR') expect(client.threads.deleteThread).not.toHaveBeenCalled() @@ -1167,7 +1167,7 @@ describe('thread rename', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'rename', '500', 'New Title']) + await program.parseAsync(['node', 'cm', 'thread', 'rename', '500', 'New Title']) expect(client.threads.updateThread).toHaveBeenCalledWith({ id: 500, title: 'New Title' }) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 renamed to "New Title".') @@ -1184,7 +1184,7 @@ describe('thread rename', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'rename', '500', @@ -1208,7 +1208,7 @@ describe('thread rename', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'thread', 'rename', '500', 'New Title', '--dry-run']), + program.parseAsync(['node', 'cm', 'thread', 'rename', '500', 'New Title', '--dry-run']), ).rejects.toThrow('thread not found') expect(client.threads.updateThread).not.toHaveBeenCalled() }) @@ -1220,7 +1220,7 @@ describe('thread rename', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'rename', '500', 'New Title', '--json']) + await program.parseAsync(['node', 'cm', 'thread', 'rename', '500', 'New Title', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(500) @@ -1239,7 +1239,7 @@ describe('thread rename', () => { await program.parseAsync([ 'node', - 'tw', + 'cm', 'thread', 'rename', '500', @@ -1270,7 +1270,7 @@ describe('thread update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'update', '500', 'New body']) + await program.parseAsync(['node', 'cm', 'thread', 'update', '500', 'New body']) expect(client.threads.updateThread).toHaveBeenCalledWith({ id: 500, @@ -1288,7 +1288,7 @@ describe('thread update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'update', '500', 'New body', '--dry-run']) + await program.parseAsync(['node', 'cm', 'thread', 'update', '500', 'New body', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would update thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') @@ -1306,7 +1306,7 @@ describe('thread update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'update', '500']) + await program.parseAsync(['node', 'cm', 'thread', 'update', '500']) expect(client.threads.updateThread).toHaveBeenCalledWith({ id: 500, @@ -1321,7 +1321,7 @@ describe('thread update', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await expect( - program.parseAsync(['node', 'tw', 'thread', 'update', '500']), + program.parseAsync(['node', 'cm', 'thread', 'update', '500']), ).rejects.toHaveProperty('code', 'MISSING_CONTENT') consoleSpy.mockRestore() @@ -1334,7 +1334,7 @@ describe('thread update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'update', '500', 'New body', '--json']) + await program.parseAsync(['node', 'cm', 'thread', 'update', '500', 'New body', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(500) @@ -1352,7 +1352,7 @@ describe('thread update', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'thread', 'update', '500', 'New body', '--dry-run']), + program.parseAsync(['node', 'cm', 'thread', 'update', '500', 'New body', '--dry-run']), ).rejects.toThrow('thread not found') expect(client.threads.updateThread).not.toHaveBeenCalled() }) @@ -1370,7 +1370,7 @@ describe('thread done', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'done', '500']) + await program.parseAsync(['node', 'cm', 'thread', 'done', '500']) expect(client.inbox.archiveThread).toHaveBeenCalledWith(500) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 archived.') @@ -1385,7 +1385,7 @@ describe('thread done', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'thread', 'done', '500', '--dry-run']) + await program.parseAsync(['node', 'cm', 'thread', 'done', '500', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would archive thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') @@ -1402,7 +1402,7 @@ describe('thread done', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'thread', 'done', '500', '--dry-run']), + program.parseAsync(['node', 'cm', 'thread', 'done', '500', '--dry-run']), ).rejects.toThrow('thread not found') expect(client.inbox.archiveThread).not.toHaveBeenCalled() }) diff --git a/src/commands/update/index.ts b/src/commands/update/index.ts index e381b73..0e32dbe 100644 --- a/src/commands/update/index.ts +++ b/src/commands/update/index.ts @@ -1,50 +1,15 @@ -import { readConfig as readConfigCore, writeConfig as writeConfigCore } from '@doist/cli-core' import { registerUpdateCommand as registerCoreUpdateCommand } from '@doist/cli-core/commands' import type { Command } from 'commander' import packageJson from '../../../package.json' with { type: 'json' } import { getConfigPath } from '../../lib/config.js' import { withSpinner } from '../../lib/spinner.js' -/** - * Bridge a legacy on-disk config (`updateChannel` only, no `update_channel`) - * to cli-core's expected canonical key. cli-core reads the file directly and - * bypasses twist's `getConfig` translation seam, so a user who set the - * channel under an older twist build and hasn't written config since the - * #211 upgrade would otherwise have their preference silently ignored. - * - * Runs at most once per `tw update*` invocation, only when the file is in - * the legacy-only state. No-op for fresh installs, canonical-only files, - * and files that already carry both keys (post-#211 dual-write). - */ -async function migrateLegacyChannelKey(configPath: string): Promise { - const raw = await readConfigCore>(configPath) - if ( - !raw || - typeof raw !== 'object' || - Array.isArray(raw) || - !('updateChannel' in raw) || - 'update_channel' in raw - ) { - return - } - const value = (raw as Record).updateChannel - await writeConfigCore(configPath, { ...raw, update_channel: value }) -} - export function registerUpdateCommand(program: Command): void { - const configPath = getConfigPath() registerCoreUpdateCommand(program, { packageName: packageJson.name, currentVersion: packageJson.version, - configPath, - changelogCommandName: 'tw changelog', + configPath: getConfigPath(), + changelogCommandName: 'cm changelog', withSpinner, }) - // Commander propagates parent hooks to subcommands, so this fires for - // both `tw update` and `tw update switch` before cli-core's action runs. - program.commands - .find((c) => c.name() === 'update') - ?.hook('preAction', async () => { - await migrateLegacyChannelKey(configPath) - }) } diff --git a/src/commands/update/update.test.ts b/src/commands/update/update.test.ts index 6ae5b16..182e419 100644 --- a/src/commands/update/update.test.ts +++ b/src/commands/update/update.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Command } from 'commander' @@ -9,7 +9,7 @@ describe('update wrapper', () => { let tmpConfigPath: string beforeEach(() => { - tmpConfigPath = join(mkdtempSync(join(tmpdir(), 'twist-update-test-')), 'config.json') + tmpConfigPath = join(mkdtempSync(join(tmpdir(), 'comms-update-test-')), 'config.json') vi.doMock('../../lib/config.js', async (importOriginal) => { const actual = await importOriginal() return { ...actual, getConfigPath: () => tmpConfigPath } @@ -49,37 +49,11 @@ describe('update wrapper', () => { packageName: packageJson.name, currentVersion: packageJson.version, configPath: tmpConfigPath, - changelogCommandName: 'tw changelog', + changelogCommandName: 'cm changelog', withSpinner, }) }) - it('migrates a legacy-only updateChannel on disk so cli-core can read it', async () => { - writeFileSync(tmpConfigPath, JSON.stringify({ updateChannel: 'pre-release' })) - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ version: packageJson.version }), - }), - ) - - const { registerUpdateCommand } = await import('./index.js') - const program = new Command() - program.exitOverride() - registerUpdateCommand(program) - - await program.parseAsync(['node', 'tw', 'update', '--check']) - - // After the preAction hook ran, the on-disk file should carry both - // keys so cli-core's `update_channel` read succeeds going forward. - const onDisk = JSON.parse(readFileSync(tmpConfigPath, 'utf-8')) - expect(onDisk).toMatchObject({ - updateChannel: 'pre-release', - update_channel: 'pre-release', - }) - }) - it('reads the persisted channel through cli-core (hermetic against the real config)', async () => { writeFileSync(tmpConfigPath, JSON.stringify({ update_channel: 'stable' })) const fetchMock = vi.fn().mockResolvedValue({ @@ -93,7 +67,7 @@ describe('update wrapper', () => { program.exitOverride() registerUpdateCommand(program) - await program.parseAsync(['node', 'tw', 'update', '--check']) + await program.parseAsync(['node', 'cm', 'update', '--check']) expect(fetchMock).toHaveBeenCalledTimes(1) const [url] = fetchMock.mock.calls[0] @@ -113,7 +87,7 @@ describe('update wrapper', () => { program.exitOverride() registerUpdateCommand(program) - await expect(program.parseAsync(['node', 'tw', 'update', '--check'])).rejects.toMatchObject( + await expect(program.parseAsync(['node', 'cm', 'update', '--check'])).rejects.toMatchObject( { code: 'INVALID_UPDATE_CHANNEL' }, ) }) diff --git a/src/commands/user.test.ts b/src/commands/user.test.ts index be0b2c2..b3ebd41 100644 --- a/src/commands/user.test.ts +++ b/src/commands/user.test.ts @@ -38,12 +38,12 @@ describe('users --workspace conflict', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'users', 'Doist', '--workspace', 'Other']), + program.parseAsync(['node', 'cm', 'users', 'Doist', '--workspace', 'Other']), ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') }) }) -describeEmptyMachineOutput('tw users empty output', { +describeEmptyMachineOutput('cm users empty output', { setup: () => { vi.clearAllMocks() apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) @@ -51,7 +51,7 @@ describeEmptyMachineOutput('tw users empty output', { }, run: async (extraArgs) => { const program = createProgram() - await program.parseAsync(['node', 'tw', 'users', ...extraArgs]) + await program.parseAsync(['node', 'cm', 'users', ...extraArgs]) }, humanMessage: 'No users found.', }) @@ -78,7 +78,7 @@ describe('user --json', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'user', '--json']) + await program.parseAsync(['node', 'cm', 'user', '--json']) expect(consoleSpy).toHaveBeenCalledTimes(1) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) @@ -96,7 +96,7 @@ describe('user --json', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'user', '--json', '--full']) + await program.parseAsync(['node', 'cm', 'user', '--json', '--full']) expect(consoleSpy).toHaveBeenCalledTimes(1) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) diff --git a/src/commands/user.ts b/src/commands/user.ts index 79df38f..ec00379 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -88,8 +88,8 @@ export function registerUserCommand(program: Command): void { 'after', ` Examples: - tw user - tw user --json`, + cm user + cm user --json`, ) .action(showCurrentUser) @@ -105,8 +105,8 @@ Examples: 'after', ` Examples: - tw users - tw users --search "Jane" --json`, + cm users + cm users --search "Jane" --json`, ) .action(listUsers) } diff --git a/src/commands/view.test.ts b/src/commands/view.test.ts index 6631b37..fe88f91 100644 --- a/src/commands/view.test.ts +++ b/src/commands/view.test.ts @@ -37,7 +37,7 @@ function createProgram() { return program } -describe('tw view routing', () => { +describe('cm view routing', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -45,7 +45,12 @@ describe('tw view routing', () => { it('routes thread URL to thread view', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'view', 'https://comms.todoist.com/a/1585/ch/100/t/200']), + program.parseAsync([ + 'node', + 'cm', + 'view', + 'https://comms.todoist.com/a/1585/ch/100/t/200', + ]), ).rejects.toThrow('ROUTED_TO_THREAD') }) @@ -54,7 +59,7 @@ describe('tw view routing', () => { await expect( program.parseAsync([ 'node', - 'tw', + 'cm', 'view', 'https://comms.todoist.com/a/1585/ch/100/t/200/c/300', ]), @@ -64,28 +69,33 @@ describe('tw view routing', () => { it('routes conversation URL to conversation view', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'view', 'https://comms.todoist.com/a/1585/msg/400']), + program.parseAsync(['node', 'cm', 'view', 'https://comms.todoist.com/a/1585/msg/400']), ).rejects.toThrow('ROUTED_TO_CONVERSATION') }) it('routes message URL to msg view', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'view', 'https://comms.todoist.com/a/1585/msg/400/m/500']), + program.parseAsync([ + 'node', + 'cm', + 'view', + 'https://comms.todoist.com/a/1585/msg/400/m/500', + ]), ).rejects.toThrow('ROUTED_TO_MSG') }) - it('throws for unrecognized Twist URL', async () => { + it('throws for unrecognized Comms URL', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'view', 'https://comms.todoist.com/a/1585']), - ).rejects.toThrow('Not a recognized Twist URL') + program.parseAsync(['node', 'cm', 'view', 'https://comms.todoist.com/a/1585']), + ).rejects.toThrow('Not a recognized Comms URL') }) - it('throws for non-Twist URL', async () => { + it('throws for non-Comms URL', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'tw', 'view', 'https://google.com/something']), - ).rejects.toThrow('Not a recognized Twist URL') + program.parseAsync(['node', 'cm', 'view', 'https://google.com/something']), + ).rejects.toThrow('Not a recognized Comms URL') }) }) diff --git a/src/commands/view.ts b/src/commands/view.ts index 92cb20e..bc7f914 100644 --- a/src/commands/view.ts +++ b/src/commands/view.ts @@ -2,8 +2,8 @@ import { Command } from 'commander' import { CliError } from '../lib/errors.js' import { classifyCommsUrl } from '../lib/refs.js' -function looksLikeTwistAppUrl(token: string): boolean { - return /^https?:\/\/twist\.com\/a\/\S+/.test(token) +function looksLikeCommsAppUrl(token: string): boolean { + return /^https?:\/\/comms\.todoist\.com\/a\/\S+/.test(token) } function extractViewInvocation(parsedUrl: string): { @@ -25,7 +25,7 @@ async function runRoutedCommand( proxy.exitOverride() const register = await loadRegister() register(proxy) - await proxy.parseAsync(['node', 'tw', ...argv]) + await proxy.parseAsync(['node', 'cm', ...argv]) } export function registerViewCommand(program: Command): void { @@ -37,25 +37,25 @@ export function registerViewCommand(program: Command): void { 'after', ` Route mapping: - Message URL → tw msg view - Conversation URL → tw conversation view - Comment URL → tw thread view (comment ID extracted from URL) - Thread URL → tw thread view + Message URL → cm msg view + Conversation URL → cm conversation view + Comment URL → cm thread view (comment ID extracted from URL) + Thread URL → cm thread view Examples: - tw view https://comms.todoist.com/a/1585/ch/100/t/200 - tw view https://comms.todoist.com/a/1585/ch/100/t/200/c/300 - tw view https://comms.todoist.com/a/1585/msg/400 - tw view https://comms.todoist.com/a/1585/msg/400/m/500 - tw view https://comms.todoist.com/a/1585/msg/400/m/500 --json`, + cm view https://comms.todoist.com/a/1585/ch/100/t/200 + cm view https://comms.todoist.com/a/1585/ch/100/t/200/c/300 + cm view https://comms.todoist.com/a/1585/msg/400 + cm view https://comms.todoist.com/a/1585/msg/400/m/500 + cm view https://comms.todoist.com/a/1585/msg/400/m/500 --json`, ) .action(async (url: string) => { const urlHints = [ 'Expected: https://comms.todoist.com/a/{workspaceId}/...', - 'Run: tw view --help for examples', + 'Run: cm view --help for examples', ] - if (!looksLikeTwistAppUrl(url)) { - throw new CliError('INVALID_URL', `Not a recognized Twist URL: ${url}`, urlHints) + if (!looksLikeCommsAppUrl(url)) { + throw new CliError('INVALID_URL', `Not a recognized Comms URL: ${url}`, urlHints) } const { url: resolvedUrl, passthroughArgs } = extractViewInvocation(url) @@ -64,7 +64,7 @@ Examples: if (!route) { throw new CliError( 'INVALID_URL', - `Not a recognized Twist URL: ${resolvedUrl}`, + `Not a recognized Comms URL: ${resolvedUrl}`, urlHints, ) } diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index 6be83f3..8d259a0 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -54,8 +54,8 @@ export function registerWorkspaceCommand(program: Command): void { 'after', ` Examples: - tw workspaces - tw workspaces --json`, + cm workspaces + cm workspaces --json`, ) .action(listWorkspaces) @@ -68,8 +68,8 @@ Examples: 'after', ` Examples: - tw workspace use "My Workspace" - tw workspace use id:1585`, + cm workspace use "My Workspace" + cm workspace use id:1585`, ) .action(useWorkspace) } diff --git a/src/index.ts b/src/index.ts index b04dbba..84a702a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,7 +83,7 @@ const commandAliases: Record = { } program - .name('tw') + .name('cm') .description('Comms CLI') .version(pkg.version) .option('--no-spinner', 'Disable loading animations') @@ -92,7 +92,7 @@ program '--include-private-channels', 'Include joined private channels in output when explicitly needed (env: COMMS_INCLUDE_PRIVATE_CHANNELS)', ) - .option('--accessible', 'Add text labels to color-coded output (also: TW_ACCESSIBLE=1)') + .option('--accessible', 'Add text labels to color-coded output (also: CM_ACCESSIBLE=1)') .option( '--non-interactive', 'Disable interactive prompts (auto-detected when stdin is not a TTY)', diff --git a/src/lib/api.ts b/src/lib/api.ts index 85effcf..0befdd5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -188,7 +188,7 @@ function wrapResult( throw new CliError( 'INSUFFICIENT_SCOPE', 'This action requires permissions your current token does not have.', - ['Run `tw auth login` to re-authenticate with the required scopes'], + ['Run `cm auth login` to re-authenticate with the required scopes'], ) } throw error diff --git a/src/lib/auth-constants.ts b/src/lib/auth-constants.ts index 7695e7f..d8dc09a 100644 --- a/src/lib/auth-constants.ts +++ b/src/lib/auth-constants.ts @@ -1,9 +1,2 @@ /** OS keyring `service` identifier for every comms-cli secret. */ export const SECURE_STORE_SERVICE = 'comms-cli' - -/** - * Legacy single-user keyring slot. `migrateLegacyAuth` deletes it after a - * successful migration; the runtime token store reads it as a last resort - * when migration can't complete (e.g. offline `identifyAccount`). - */ -export const LEGACY_KEYRING_ACCOUNT = 'api-token' diff --git a/src/lib/auth-pages.ts b/src/lib/auth-pages.ts index e9a8c9a..04f908d 100644 --- a/src/lib/auth-pages.ts +++ b/src/lib/auth-pages.ts @@ -14,8 +14,8 @@ export function renderSuccess(): string { --text: #1d1d1f; --text-secondary: #6e6e73; --text-muted: #aeaeb2; - --twist-teal: #0dbed9; - --twist-teal-soft: rgba(13, 190, 217, 0.06); + --comms-teal: #0dbed9; + --comms-teal-soft: rgba(13, 190, 217, 0.06); --green: #058527; --terminal-bg: #1a1b26; --terminal-text: #c0caf5; @@ -141,7 +141,7 @@ export function renderSuccess(): string { color: var(--terminal-text); } .line:last-child { margin-bottom: 0; } - .ps { color: var(--twist-teal); user-select: none; font-weight: 500; } + .ps { color: var(--comms-teal); user-select: none; font-weight: 500; } .arg { color: var(--terminal-green); } .out { color: var(--terminal-muted); @@ -156,7 +156,7 @@ export function renderSuccess(): string { display: inline-block; width: 8px; height: 16px; - background: var(--twist-teal); + background: var(--comms-teal); border-radius: 1px; animation: blink 1.2s step-end infinite; } @@ -178,22 +178,22 @@ export function renderSuccess(): string { flex-shrink: 0; width: 34px; height: 34px; - background: var(--twist-teal-soft); + background: var(--comms-teal-soft); border-radius: 8px; display: flex; align-items: center; justify-content: center; } - .info-icon svg { width: 16px; height: 16px; color: var(--twist-teal); } + .info-icon svg { width: 16px; height: 16px; color: var(--comms-teal); } .info-text h4 { font-size: 13px; font-weight: 600; margin-bottom: 2px; } .info-text p { font-size: 13px; color: var(--text-secondary); line-height: 1.5; } .info-text code { font-family: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; - background: var(--twist-teal-soft); + background: var(--comms-teal-soft); padding: 2px 6px; border-radius: 4px; - color: var(--twist-teal); + color: var(--comms-teal); } footer { margin-top: 20px; @@ -219,7 +219,7 @@ export function renderSuccess(): string { font-size: 12px; transition: color 0.2s; } - .gh a:hover { color: var(--twist-teal); } + .gh a:hover { color: var(--comms-teal); } .gh svg { width: 14px; height: 14px; } @media (max-width: 480px) { .container { padding: 32px 16px; } @@ -237,8 +237,8 @@ export function renderSuccess(): string { - - + + @@ -398,13 +398,13 @@ export function renderSuccess(): string {
$ - tw + cm inbox
5 unread threads
$ - tw + cm compose "Weekly update"
@@ -425,7 +425,7 @@ export function renderSuccess(): string {

Return to your terminal

-

You can close this window. Run tw --help to see available commands.

+

You can close this window. Run cm --help to see available commands.

@@ -552,8 +552,8 @@ export function renderError(errorMessage: string): string { - - + + @@ -700,7 +700,7 @@ export function renderError(errorMessage: string): string {

Authentication failed

${errorMessage}

-
Try again with tw auth login
+
Try again with cm auth login
` diff --git a/src/lib/auth-provider.test.ts b/src/lib/auth-provider.test.ts index db7eab0..c02d195 100644 --- a/src/lib/auth-provider.test.ts +++ b/src/lib/auth-provider.test.ts @@ -2,17 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('./api.js', () => ({ createWrappedCommsClient: vi.fn() })) -const migrateMocks = vi.hoisted(() => ({ - runMigrateLegacyAuth: vi.fn(), -})) - -vi.mock('./migrate-auth.js', () => migrateMocks) - const keyringMocks = vi.hoisted(() => ({ createKeyringTokenStore: vi.fn(), - createSecureStore: vi.fn(), - secureStoreGetSecret: vi.fn(), - secureStoreDeleteSecret: vi.fn(), inner: { active: vi.fn(), set: vi.fn(), @@ -27,21 +18,14 @@ const keyringMocks = vi.hoisted(() => ({ vi.mock('@doist/cli-core/auth', async (importOriginal) => { const actual = await importOriginal() keyringMocks.createKeyringTokenStore.mockImplementation(() => keyringMocks.inner) - keyringMocks.createSecureStore.mockImplementation(() => ({ - getSecret: keyringMocks.secureStoreGetSecret, - setSecret: vi.fn(), - deleteSecret: keyringMocks.secureStoreDeleteSecret, - })) return { ...actual, createKeyringTokenStore: keyringMocks.createKeyringTokenStore, - createSecureStore: keyringMocks.createSecureStore, } }) const configMocks = vi.hoisted(() => ({ getConfig: vi.fn(), - updateConfig: vi.fn(), })) vi.mock('./config.js', async (importOriginal) => { @@ -50,14 +34,13 @@ vi.mock('./config.js', async (importOriginal) => { ...actual, getConfigPath: () => '/home/user/.config/comms-cli/config.json', getConfig: configMocks.getConfig, - updateConfig: configMocks.updateConfig, } }) import { createWrappedCommsClient } from './api.js' import { AUTHORIZATION_URL, - createTwistAuthProvider, + createCommsAuthProvider, matchCommsAccount, READ_ONLY_SCOPES, READ_WRITE_SCOPES, @@ -76,22 +59,8 @@ const STORED_ACCOUNT = { authScope: 'user:read', } -const SKIPPED_RESULT = { - status: 'skipped', - reason: 'identify-failed', - detail: 'offline', -} as const - -const LEGACY_CONFIG = { - authUserId: 42, - authUserName: 'Ada', - authMode: 'read-write' as const, - authScope: 'user:read', -} - const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }) -/** Reset the module-level migration memo for each test by re-importing. */ async function loadCreateCommsTokenStore(): Promise< typeof import('./auth-provider.js').createCommsTokenStore > { @@ -100,7 +69,7 @@ async function loadCreateCommsTokenStore(): Promise< return mod.createCommsTokenStore } -describe('createTwistAuthProvider', () => { +describe('createCommsAuthProvider', () => { let fetchSpy: ReturnType beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch') @@ -112,7 +81,7 @@ describe('createTwistAuthProvider', () => { it('prepare POSTs the DCR payload and surfaces clientId/clientSecret on the handshake', async () => { fetchSpy.mockResolvedValue(json({ client_id: 'twd_abc', client_secret: 'shh' })) - const result = await createTwistAuthProvider().prepare!({ + const result = await createCommsAuthProvider().prepare!({ redirectUri: REDIRECT_URI, flags: {}, }) @@ -128,7 +97,7 @@ describe('createTwistAuthProvider', () => { }) it('prepare rewraps fetch rejections + bad responses as AUTH_FAILED', async () => { - const provider = createTwistAuthProvider() + const provider = createCommsAuthProvider() const ctx = { redirectUri: REDIRECT_URI, flags: {} } fetchSpy.mockRejectedValueOnce(new TypeError('fetch failed')) @@ -141,8 +110,8 @@ describe('createTwistAuthProvider', () => { await expect(provider.prepare!(ctx)).rejects.toMatchObject({ code: 'AUTH_FAILED' }) }) - it('authorize builds the twist URL with PKCE params and threads verifier + authMode forward', async () => { - const result = await createTwistAuthProvider().authorize({ + it('authorize builds the Comms URL with PKCE params and threads verifier + authMode forward', async () => { + const result = await createCommsAuthProvider().authorize({ redirectUri: REDIRECT_URI, state: 'state-xyz', scopes: READ_WRITE_SCOPES, @@ -164,7 +133,7 @@ describe('createTwistAuthProvider', () => { }) it('authorize marks authMode read-only when readOnly is true', async () => { - const result = await createTwistAuthProvider().authorize({ + const result = await createCommsAuthProvider().authorize({ redirectUri: REDIRECT_URI, state: 's', scopes: READ_ONLY_SCOPES, @@ -178,7 +147,7 @@ describe('createTwistAuthProvider', () => { it('exchangeCode POSTs to the token endpoint with HTTP Basic auth and the PKCE verifier', async () => { fetchSpy.mockResolvedValue(json({ access_token: 'tk_123' })) - const result = await createTwistAuthProvider().exchangeCode({ + const result = await createCommsAuthProvider().exchangeCode({ code: 'auth-code', state: 's', redirectUri: REDIRECT_URI, @@ -196,7 +165,7 @@ describe('createTwistAuthProvider', () => { }) it('exchangeCode rewraps fetch rejections, bad responses, and missing-verifier as AUTH_FAILED', async () => { - const provider = createTwistAuthProvider() + const provider = createCommsAuthProvider() const goodHs = { clientId: 'a', clientSecret: 'b', codeVerifier: 'v' } const base = { code: 'c', state: 's', redirectUri: REDIRECT_URI } @@ -221,7 +190,7 @@ describe('createTwistAuthProvider', () => { users: { getSessionUser: vi.fn().mockResolvedValue({ id: 42, name: 'Ada' }) }, } as unknown as ReturnType) - const account = await createTwistAuthProvider().validateToken({ + const account = await createCommsAuthProvider().validateToken({ token: 'tk_new', handshake: { authMode: 'read-write', authScope: 'user:read' }, }) @@ -239,19 +208,12 @@ describe('createTwistAuthProvider', () => { describe('createCommsTokenStore', () => { beforeEach(() => { keyringMocks.createKeyringTokenStore.mockClear() - keyringMocks.createSecureStore.mockClear() - keyringMocks.secureStoreGetSecret.mockReset().mockResolvedValue(null) - keyringMocks.secureStoreDeleteSecret.mockReset().mockResolvedValue(true) keyringMocks.inner.active.mockReset() keyringMocks.inner.set.mockReset().mockResolvedValue(undefined) keyringMocks.inner.clear.mockReset().mockResolvedValue(undefined) keyringMocks.inner.list.mockReset().mockResolvedValue([]) keyringMocks.inner.setDefault.mockReset().mockResolvedValue(undefined) - migrateMocks.runMigrateLegacyAuth - .mockReset() - .mockResolvedValue({ status: 'no-legacy-state' }) configMocks.getConfig.mockReset().mockResolvedValue({}) - configMocks.updateConfig.mockReset().mockResolvedValue(undefined) }) afterEach(() => { @@ -270,7 +232,7 @@ describe('createCommsTokenStore', () => { expect(options.matchAccount).toBe(matcher) }) - it('active() short-circuits to COMMS_API_TOKEN when no explicit ref is supplied (and never even awaits migration)', async () => { + 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() @@ -281,7 +243,6 @@ describe('createCommsTokenStore', () => { account: { id: '', label: '', authMode: 'unknown', authScope: '' }, }) expect(keyringMocks.inner.active).not.toHaveBeenCalled() - expect(migrateMocks.runMigrateLegacyAuth).not.toHaveBeenCalled() }) it('active() ignores COMMS_API_TOKEN when an explicit --user ref targets a stored account', async () => { @@ -294,139 +255,28 @@ describe('createCommsTokenStore', () => { expect(keyringMocks.inner.active).toHaveBeenCalledWith('42') }) - it('runs runMigrateLegacyAuth on the first store access and memoises across subsequent calls', async () => { - keyringMocks.inner.active.mockResolvedValue(null) - const createCommsTokenStore = await loadCreateCommsTokenStore() - const store = createCommsTokenStore() - - await store.active('42') - await store.list() - await store.clear('42') - await store.set(STORED_ACCOUNT, 'tk') - await store.setDefault('42') - - // Memoised: migration must run exactly once across mixed reads + writes. - expect(migrateMocks.runMigrateLegacyAuth).toHaveBeenCalledTimes(1) - expect(migrateMocks.runMigrateLegacyAuth).toHaveBeenCalledWith({ silent: true }) - }) - - it('falls back to the legacy api-token keyring slot when migration is skipped (offline `identifyAccount`)', async () => { - migrateMocks.runMigrateLegacyAuth.mockResolvedValue(SKIPPED_RESULT) - keyringMocks.secureStoreGetSecret.mockResolvedValue('tk_legacy_keyring') - configMocks.getConfig.mockResolvedValue(LEGACY_CONFIG) - const createCommsTokenStore = await loadCreateCommsTokenStore() - - const snapshot = await createCommsTokenStore().active() - - expect(keyringMocks.createSecureStore).toHaveBeenCalledWith({ - serviceName: 'comms-cli', - account: 'api-token', - }) - expect(snapshot).toEqual({ - token: 'tk_legacy_keyring', - account: { id: '42', label: 'Ada', authMode: 'read-write', authScope: 'user:read' }, - }) - // v2 path is not consulted when the legacy fallback succeeded. - expect(keyringMocks.inner.active).not.toHaveBeenCalled() - }) - - it('falls back to plaintext config.token when migration is skipped and the keyring is empty', async () => { - migrateMocks.runMigrateLegacyAuth.mockResolvedValue(SKIPPED_RESULT) - configMocks.getConfig.mockResolvedValue({ - ...LEGACY_CONFIG, - token: ' tk_legacy_plaintext ', - authMode: 'read-only', - }) - const createCommsTokenStore = await loadCreateCommsTokenStore() - - const snapshot = await createCommsTokenStore().active() - - expect(snapshot?.token).toBe('tk_legacy_plaintext') - expect(snapshot?.account.authMode).toBe('read-only') - }) - - it('delegates to the v2 store when migration is conclusive (no-legacy-state) — no legacy read attempt', async () => { - migrateMocks.runMigrateLegacyAuth.mockResolvedValue({ status: 'no-legacy-state' }) + it('delegates to the cli-core store when no env token is set', async () => { keyringMocks.inner.active.mockResolvedValue({ token: 'tk_v2', account: STORED_ACCOUNT }) const createCommsTokenStore = await loadCreateCommsTokenStore() const snapshot = await createCommsTokenStore().active() expect(snapshot).toEqual({ token: 'tk_v2', account: STORED_ACCOUNT }) - expect(keyringMocks.createSecureStore).not.toHaveBeenCalled() - }) - - it('falls back to legacy when runMigrateLegacyAuth rejects (catch branch of ensureMigrated)', async () => { - migrateMocks.runMigrateLegacyAuth.mockRejectedValue(new Error('boom')) - keyringMocks.secureStoreGetSecret.mockResolvedValue('tk_legacy_keyring') - configMocks.getConfig.mockResolvedValue(LEGACY_CONFIG) - const createCommsTokenStore = await loadCreateCommsTokenStore() - - const snapshot = await createCommsTokenStore().active() - - expect(snapshot?.token).toBe('tk_legacy_keyring') - expect(snapshot?.account.id).toBe('42') - expect(keyringMocks.inner.active).not.toHaveBeenCalled() - }) - - it('legacy snapshot synthesises account.id = "" for `tw auth token ` users with no authUserId on disk', async () => { - migrateMocks.runMigrateLegacyAuth.mockResolvedValue(SKIPPED_RESULT) - configMocks.getConfig.mockResolvedValue({ token: 'tk_token_only', authMode: 'unknown' }) - const createCommsTokenStore = await loadCreateCommsTokenStore() - - const snapshot = await createCommsTokenStore().active() - - expect(snapshot?.account.id).toBe('') - expect(snapshot?.account.label).toBe('') - }) - - it('active(ref) returns the legacy snapshot when ref matches, falls through to v2 when it doesn’t', async () => { - migrateMocks.runMigrateLegacyAuth.mockResolvedValue(SKIPPED_RESULT) - keyringMocks.secureStoreGetSecret.mockResolvedValue('tk_legacy_keyring') - configMocks.getConfig.mockResolvedValue(LEGACY_CONFIG) - keyringMocks.inner.active.mockResolvedValue(null) - const createCommsTokenStore = await loadCreateCommsTokenStore() - const store = createCommsTokenStore() - - const matched = await store.active('42') - expect(matched?.token).toBe('tk_legacy_keyring') - - const mismatched = await store.active('999') - expect(mismatched).toBeNull() - expect(keyringMocks.inner.active).toHaveBeenCalledWith('999') }) - it('set() / clear() discharge legacy state on disk when migration is inconclusive', async () => { - migrateMocks.runMigrateLegacyAuth.mockResolvedValue(SKIPPED_RESULT) + it('set/clear/list/setDefault delegate to the cli-core store', async () => { const createCommsTokenStore = await loadCreateCommsTokenStore() const store = createCommsTokenStore() await store.set(STORED_ACCOUNT, 'tk_new') await store.clear('42') + await store.list() + await store.setDefault('42') - expect(keyringMocks.secureStoreDeleteSecret).toHaveBeenCalledTimes(2) - expect(configMocks.updateConfig).toHaveBeenCalledWith({ - token: undefined, - authMode: undefined, - authScope: undefined, - authUserId: undefined, - authUserName: undefined, - pendingSecureStoreClear: undefined, - }) expect(keyringMocks.inner.set).toHaveBeenCalledWith(STORED_ACCOUNT, 'tk_new') expect(keyringMocks.inner.clear).toHaveBeenCalledWith('42') - }) - - it('set() / clear() do NOT touch legacy state when migration is conclusive (no needless writes on the happy path)', async () => { - migrateMocks.runMigrateLegacyAuth.mockResolvedValue({ status: 'no-legacy-state' }) - const createCommsTokenStore = await loadCreateCommsTokenStore() - const store = createCommsTokenStore() - - await store.set(STORED_ACCOUNT, 'tk_new') - await store.clear('42') - - expect(keyringMocks.secureStoreDeleteSecret).not.toHaveBeenCalled() - expect(configMocks.updateConfig).not.toHaveBeenCalled() + expect(keyringMocks.inner.list).toHaveBeenCalledTimes(1) + expect(keyringMocks.inner.setDefault).toHaveBeenCalledWith('42') }) }) diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts index 0bbce9e..c057246 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -3,19 +3,16 @@ import { type AuthAccount, type AuthProvider, createKeyringTokenStore, - createSecureStore, deriveChallenge, generateVerifier, type KeyringTokenStore, - type MigrateAuthResult, } from '@doist/cli-core/auth' import { createWrappedCommsClient } from './api.js' -import { LEGACY_KEYRING_ACCOUNT, SECURE_STORE_SERVICE } from './auth-constants.js' -import { type AuthMode, getConfig, getConfigPath, updateConfig } from './config.js' +import { SECURE_STORE_SERVICE } from './auth-constants.js' +import { toCommsAccount } from './comms-account.js' +import { type AuthMode, getConfig, getConfigPath } from './config.js' import { CliError } from './errors.js' -import { runMigrateLegacyAuth } from './migrate-auth.js' import { parseRef } from './refs.js' -import { makeCommsAccount, toCommsAccount } from './comms-account.js' import { createCommsUserRecordStore, getDefaultUserRecord } from './user-records.js' export const AUTHORIZATION_URL = 'https://comms.todoist.com/oauth/authorize' @@ -58,11 +55,11 @@ export const READ_ONLY_SCOPES = [ 'notifications:read', ] -const AUTH_HINTS = ['Try again: tw auth login', 'Or set COMMS_API_TOKEN environment variable'] +const AUTH_HINTS = ['Try again: cm auth login', 'Or set COMMS_API_TOKEN environment variable'] /** * Narrow account shape: only fields that round-trip through the local token - * store. `id` is the stringified numeric Twist user id (so cli-core's + * store. `id` is the stringified numeric Comms user id (so cli-core's * `AuthAccount.id` string contract holds), `label` is the user's display * name. Richer session-user details are fetched on demand via the API * rather than threaded through the auth flow. @@ -76,7 +73,7 @@ export type CommsAccount = AuthAccount & { export type CommsTokenStore = KeyringTokenStore -type TwistHandshake = Record & { +type CommsHandshake = Record & { clientId: string clientSecret: string codeVerifier?: string @@ -84,8 +81,8 @@ type TwistHandshake = Record & { authScope?: string } -function asHandshake(value: Record): TwistHandshake { - return value as TwistHandshake +function asHandshake(value: Record): CommsHandshake { + return value as CommsHandshake } function authFailed(message: string, cause?: unknown): CliError { @@ -144,11 +141,11 @@ async function registerDynamicClient( return { clientId: result.client_id, clientSecret: result.client_secret } } -export function createTwistAuthProvider(): AuthProvider { +export function createCommsAuthProvider(): AuthProvider { return { async prepare({ redirectUri }) { const { clientId, clientSecret } = await registerDynamicClient(redirectUri) - const handshake: TwistHandshake = { clientId, clientSecret } + const handshake: CommsHandshake = { clientId, clientSecret } return { handshake } }, @@ -169,7 +166,7 @@ export function createTwistAuthProvider(): AuthProvider { code_challenge_method: 'S256', }) - const nextHandshake: TwistHandshake = { + const nextHandshake: CommsHandshake = { ...hs, codeVerifier, authMode, @@ -277,98 +274,9 @@ export function matchCommsAccount(account: CommsAccount, ref: AccountRef): boole const TOKEN_ENV_VAR = 'COMMS_API_TOKEN' -/** True when the v2 store is the authoritative source. */ -function migrationIsConclusive(result: MigrateAuthResult): boolean { - return ( - result.status === 'migrated' || - result.status === 'already-migrated' || - result.status === 'no-legacy-state' - ) -} - -/** - * Synthesise a snapshot from v1 state still on disk (legacy keyring slot, - * then plaintext `config.token`). Fallback for when migration can't complete. - * Token-only users with no `authUserId` get `account.id = ''`. - */ -async function readLegacyTokenSnapshot(): Promise<{ - token: string - account: CommsAccount -} | null> { - const fromKeyring = await createSecureStore({ - serviceName: SECURE_STORE_SERVICE, - account: LEGACY_KEYRING_ACCOUNT, - }) - .getSecret() - .catch(() => null) - const config = await getConfig() - const token = fromKeyring || config.token?.trim() || null - if (!token) return null - return { - token, - account: makeCommsAccount({ - id: config.authUserId !== undefined ? String(config.authUserId) : '', - label: config.authUserName ?? '', - authMode: config.authMode, - authScope: config.authScope, - }), - } -} - /** - * Clear the legacy keyring slot + v1 flat config fields. Runs before a - * write/clear when migration is inconclusive so v2 writes aren't shadowed - * by a stale legacy token. Best-effort — failures leave legacy in place. - */ -async function dischargeLegacyState(): Promise { - await Promise.allSettled([ - createSecureStore({ - serviceName: SECURE_STORE_SERVICE, - account: LEGACY_KEYRING_ACCOUNT, - }).deleteSecret(), - updateConfig({ - token: undefined, - authMode: undefined, - authScope: undefined, - authUserId: undefined, - authUserName: undefined, - pendingSecureStoreClear: undefined, - }), - ]) -} - -/** - * Memoised one-shot migration trigger. Resolves with `null` on rejection - * so the CLI never fails to start because of a migration error — the - * legacy snapshot fallback below handles that case. Tests reset the memo - * with `vi.resetModules()` + a dynamic re-import. - */ -let migrationPromise: Promise | null> | undefined -function ensureMigrated(): Promise | null> { - if (!migrationPromise) { - migrationPromise = runMigrateLegacyAuth({ silent: true }).catch(() => null) - } - return migrationPromise -} - -/** - * True when the v2 store is empty but a legacy v1 token snapshot is still - * the only thing keeping the CLI authenticated — typically because - * `migrateLegacyAuth` couldn't reach the Comms API to identify the account - * (`MigrateSkipReason: 'identify-failed'`). Account-management commands - * use this to fail with a dedicated `AUTH_MIGRATION_PENDING` envelope - * instead of a misleading `ACCOUNT_NOT_FOUND`. - */ -export async function isLegacyAuthActive(): Promise { - const result = await ensureMigrated() - if (result !== null && migrationIsConclusive(result)) return false - const legacy = await readLegacyTokenSnapshot() - return legacy !== null -} - -/** - * Resolve a `ref` against the v2 store, returning the canonical account. - * Throws `ACCOUNT_NOT_FOUND` on a miss. Shared between the `tw account ...` + * Resolve a `ref` against the local store, returning the canonical account. + * Throws `ACCOUNT_NOT_FOUND` on a miss. Shared between the `cm account ...` * commands and `withUserRefAware` so the same hint reaches every caller. */ export async function findAccountInStore( @@ -379,7 +287,7 @@ export async function findAccountInStore( const match = records.find(({ account }) => matchCommsAccount(account, ref)) if (!match) { throw new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`, [ - 'Run: tw account list', + 'Run: cm account list', ]) } return match.account @@ -389,13 +297,6 @@ export async function findAccountInStore( * `COMMS_API_TOKEN` short-circuits `active()` only when no explicit ref is * supplied — cli-core's `KeyringTokenStore` doesn't know about the env var, * and an explicit ref means the caller targets a specific stored account. - * - * `ensureMigrated()` runs on every stored-state op so `--ignore-scripts` - * installs still migrate on first command. When migration isn't conclusive: - * - `active()` falls back to the legacy snapshot, honouring `ref` so it - * can't resolve to a different account than the caller asked for. - * - `set()` / `clear()` discharge legacy state on disk first so v2 writes - * aren't shadowed by a stale v1 token on the next read. */ export function createCommsTokenStore(): CommsTokenStore { const inner = createKeyringTokenStore({ @@ -404,12 +305,6 @@ export function createCommsTokenStore(): CommsTokenStore { recordsLocation: getConfigPath(), matchAccount: matchCommsAccount, }) - async function maybeDischargeLegacy(): Promise { - const result = await ensureMigrated() - if (result === null || !migrationIsConclusive(result)) { - await dischargeLegacyState() - } - } return Object.assign(Object.create(inner) as CommsTokenStore, { async active(ref?: AccountRef) { if (ref === undefined) { @@ -421,29 +316,18 @@ export function createCommsTokenStore(): CommsTokenStore { } } } - const result = await ensureMigrated() - if (result === null || !migrationIsConclusive(result)) { - const legacy = await readLegacyTokenSnapshot() - if (legacy && (ref === undefined || matchCommsAccount(legacy.account, ref))) { - return legacy - } - } return inner.active(ref) }, async set(account: CommsAccount, token: string) { - await maybeDischargeLegacy() return inner.set(account, token) }, async clear(ref?: AccountRef) { - await maybeDischargeLegacy() return inner.clear(ref) }, async list() { - await ensureMigrated() return inner.list() }, async setDefault(ref: AccountRef) { - await ensureMigrated() return inner.setDefault(ref) }, }) @@ -451,15 +335,13 @@ export function createCommsTokenStore(): CommsTokenStore { /** * Where the currently-active token lives. Returns `'config-file'` whenever - * a plaintext token is on disk — including the legacy `config.token` slot — - * so doctor/config-view reports the security-relevant state accurately. + * a plaintext token is on disk so doctor/config-view reports the + * security-relevant state accurately. */ export async function getActiveTokenSource(): Promise<'env' | 'secure-store' | 'config-file'> { if (process.env[TOKEN_ENV_VAR]) return 'env' const config = await getConfig() const record = getDefaultUserRecord(config) if (record?.fallbackToken) return 'config-file' - if (record) return 'secure-store' - if (config.token?.trim()) return 'config-file' return 'secure-store' } diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts index 4ae52ca..f5f7f57 100644 --- a/src/lib/auth.test.ts +++ b/src/lib/auth.test.ts @@ -137,29 +137,4 @@ describe('auth shims over the cli-core keyring store', () => { await expect(getAuthMetadata()).resolves.toEqual({ authMode: 'unknown', source: 'config' }) }) - - it('getAuthMetadata falls back to v1 flat fields when users[] is empty but legacy state is on disk', async () => { - // Preserves real authMode so ensureWriteAllowed's READ_ONLY guard fires. - mocks.getConfigMock.mockResolvedValueOnce({ - token: 'tk_legacy', - authMode: 'read-only', - authScope: 'user:read', - authUserId: 42, - authUserName: 'Ada', - } satisfies Config) - await expect(getAuthMetadata()).resolves.toEqual({ - authMode: 'read-only', - authScope: 'user:read', - authUserId: 42, - authUserName: 'Ada', - source: 'config', - }) - - // `tw auth token` users have no authMode → defaults to 'unknown'. - mocks.getConfigMock.mockResolvedValueOnce({ token: 'tk_token_only' } satisfies Config) - await expect(getAuthMetadata()).resolves.toEqual({ - authMode: 'unknown', - source: 'config', - }) - }) }) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1d2ff11..54a4304 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -43,8 +43,8 @@ export class NoTokenError extends CliError { constructor() { super( 'NO_TOKEN', - `No API token found. Set ${TOKEN_ENV_VAR} or run \`tw auth login\` or \`tw auth token \`.`, - ['Set COMMS_API_TOKEN or run: tw auth login'], + `No API token found. Set ${TOKEN_ENV_VAR} or run \`cm auth login\` or \`cm auth token \`.`, + ['Set COMMS_API_TOKEN or run: cm auth login'], 'info', ) this.name = 'NoTokenError' @@ -58,7 +58,7 @@ export async function getApiToken(): Promise { return snapshot.token } -/** Token + metadata in one round-trip for `tw config view` / `tw doctor`. */ +/** Token + metadata in one round-trip for `cm config view` / `cm doctor`. */ export async function probeApiToken(): Promise { const snapshot = await createCommsTokenStore().active() if (!snapshot) throw new NoTokenError() @@ -72,25 +72,12 @@ export async function probeApiToken(): Promise { } } -/** - * Auth metadata for `tw auth status` and `ensureWriteAllowed`. Falls back - * to v1 flat fields when no v2 record exists so a legacy `read-only` token - * isn't reported as `'unknown'` — that would skip the local READ_ONLY guard. - */ +/** Auth metadata for `cm auth status` and `ensureWriteAllowed`. */ export async function getAuthMetadata(): Promise { if (process.env[TOKEN_ENV_VAR]) return { authMode: 'unknown', source: 'env' } const config = await getConfig() const record = getDefaultUserRecord(config) if (record) return { ...toAccountFields(record.account), source: 'config' } - if (config.token?.trim() || config.authUserId !== undefined || config.authMode) { - return { - authMode: config.authMode ?? 'unknown', - authScope: config.authScope, - authUserId: config.authUserId, - authUserName: config.authUserName, - source: 'config', - } - } return { authMode: 'unknown', source: 'config' } } diff --git a/src/lib/completion.test.ts b/src/lib/completion.test.ts index ca77924..2341d7f 100644 --- a/src/lib/completion.test.ts +++ b/src/lib/completion.test.ts @@ -10,7 +10,7 @@ import { function createTestProgram(): Command { const program = new Command() - program.name('tw') + program.name('cm') const thread = program.command('thread').description('Thread operations') thread.command('view').description('View thread').option('--json', 'Output as JSON') @@ -98,12 +98,12 @@ describe('getCompletions', () => { describe('parseCompLine quoted argument limitation', () => { it('splits quoted multi-word arguments into separate tokens', () => { - const result = parseCompLine('tw thread reply "hello world"') + const result = parseCompLine('cm thread reply "hello world"') expect(result).toEqual(['thread', 'reply', '"hello', 'world"']) }) it('strips completion-server token', () => { - const result = parseCompLine('tw completion-server thread rep') + const result = parseCompLine('cm completion-server thread rep') expect(result).toEqual(['thread', 'rep']) }) }) diff --git a/src/lib/completion.ts b/src/lib/completion.ts index 6d96d11..58a9330 100644 --- a/src/lib/completion.ts +++ b/src/lib/completion.ts @@ -32,7 +32,7 @@ export function withCaseInsensitiveChoices(opt: Option, values: string[]): Optio * tabtab exposes shell-provided tokenized words. */ export function parseCompLine(compLine: string): string[] { - const words = compLine.split(/\s+/).slice(1) // remove binary name (tw) + const words = compLine.split(/\s+/).slice(1) // remove binary name (cm) if (words[0] === 'completion-server') words.shift() return words } diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 525a004..bc22b6a 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -73,28 +73,19 @@ describe('validateConfigForDoctor', () => { ) }) - it('accepts both updateChannel and update_channel with valid values', () => { - expect(validateConfigForDoctor({ updateChannel: 'stable' })).toEqual([]) + it('accepts update_channel with valid values', () => { expect(validateConfigForDoctor({ update_channel: 'pre-release' })).toEqual([]) + expect(validateConfigForDoctor({ update_channel: 'stable' })).toEqual([]) }) - it('rejects invalid values on either key', () => { - expect(validateConfigForDoctor({ updateChannel: 'beta' })).toContain( - 'updateChannel must be one of: stable, pre-release', - ) + it('rejects invalid update_channel values', () => { expect(validateConfigForDoctor({ update_channel: 'beta' })).toContain( 'update_channel must be one of: stable, pre-release', ) }) - it('does not flag updateChannel as unrecognized (legacy alias)', () => { - const issues = validateConfigForDoctor({ updateChannel: 'pre-release' }) - expect(issues.some((i) => i.includes('unrecognized'))).toBe(false) - }) - - it('accepts a well-formed v2 schema (config_version, defaultUserId, users[])', () => { + it('accepts a well-formed schema (defaultUserId, users[])', () => { const issues = validateConfigForDoctor({ - config_version: 2, defaultUserId: '42', users: [ { id: '42', name: 'Ada', authMode: 'read-write', authScope: 'user:read' }, @@ -104,13 +95,7 @@ describe('validateConfigForDoctor', () => { expect(issues).toEqual([]) }) - it('rejects malformed top-level v2 fields', () => { - expect(validateConfigForDoctor({ config_version: 'two' })).toContain( - 'config_version must be an integer', - ) - expect(validateConfigForDoctor({ config_version: 2.5 })).toContain( - 'config_version must be an integer', - ) + it('rejects malformed top-level fields', () => { expect(validateConfigForDoctor({ defaultUserId: 42 })).toContain( 'defaultUserId must be a string', ) @@ -143,27 +128,12 @@ describe('validateConfigForDoctor', () => { }) describe('persistence-seam translation', () => { - it('getConfig exposes the legacy updateChannel value as updateChannel in memory', async () => { - mockReadConfigCore.mockResolvedValueOnce({ updateChannel: 'pre-release' }) - await expect(getConfig()).resolves.toEqual({ updateChannel: 'pre-release' }) - }) - - it('getConfig prefers update_channel when both are on disk (cli-core wrote last)', async () => { - mockReadConfigCore.mockResolvedValueOnce({ - updateChannel: 'stable', - update_channel: 'pre-release', - }) + it('getConfig translates on-disk update_channel to in-memory updateChannel', async () => { + mockReadConfigCore.mockResolvedValueOnce({ update_channel: 'pre-release' }) await expect(getConfig()).resolves.toEqual({ updateChannel: 'pre-release' }) }) - it('getConfig returns the canonical key as updateChannel when only it is on disk', async () => { - mockReadConfigCore.mockResolvedValueOnce({ update_channel: 'stable' }) - await expect(getConfig()).resolves.toEqual({ updateChannel: 'stable' }) - }) - it('getConfig guards against a manually-edited config that is not an object', async () => { - // `null`, primitive JSON, or arrays are all valid JSON. The legacy - // migration must not crash on `in` checks against them. mockReadConfigCore.mockResolvedValueOnce(null as never) await expect(getConfig()).resolves.toEqual({}) @@ -174,10 +144,10 @@ describe('persistence-seam translation', () => { await expect(getConfig()).resolves.toEqual({}) }) - it('readConfigStrict translates legacy key on the present branch', async () => { + it('readConfigStrict translates update_channel on the present branch', async () => { mockReadConfigStrictCore.mockResolvedValueOnce({ state: 'present', - config: { updateChannel: 'pre-release' }, + config: { update_channel: 'pre-release' }, }) await expect(readConfigStrict()).resolves.toEqual({ state: 'present', @@ -195,15 +165,15 @@ describe('readConfigStrict wrapper', () => { it('passes the present state through with a Config-shaped cast', async () => { mockReadConfigStrictCore.mockResolvedValueOnce({ state: 'present', - config: { currentWorkspace: 42, authMode: 'read-write' }, + config: { currentWorkspace: 42, defaultUserId: '1' }, }) await expect(readConfigStrict()).resolves.toEqual({ state: 'present', - config: { currentWorkspace: 42, authMode: 'read-write' }, + config: { currentWorkspace: 42, defaultUserId: '1' }, }) }) - it('translates read-failed to CONFIG_READ_FAILED with twist hint copy', async () => { + it('translates read-failed to CONFIG_READ_FAILED with comms hint copy', async () => { mockReadConfigStrictCore.mockResolvedValueOnce({ state: 'read-failed', error: new Error('EACCES: permission denied'), @@ -211,7 +181,7 @@ describe('readConfigStrict wrapper', () => { await expect(readConfigStrict()).rejects.toMatchObject({ code: 'CONFIG_READ_FAILED', message: expect.stringContaining('EACCES: permission denied'), - hints: ['Check file permissions, or run `tw doctor` to diagnose'], + hints: ['Check file permissions, or run `cm doctor` to diagnose'], }) }) @@ -224,7 +194,7 @@ describe('readConfigStrict wrapper', () => { code: 'CONFIG_INVALID_JSON', message: expect.stringContaining('Unexpected token'), hints: [ - 'Fix the JSON by hand, or delete the file and re-authenticate with `tw auth login`', + 'Fix the JSON by hand, or delete the file and re-authenticate with `cm auth login`', ], }) }) @@ -238,7 +208,7 @@ describe('readConfigStrict wrapper', () => { code: 'CONFIG_INVALID_SHAPE', message: expect.stringContaining('got array'), hints: [ - 'Fix the JSON by hand, or delete the file and re-authenticate with `tw auth login`', + 'Fix the JSON by hand, or delete the file and re-authenticate with `cm auth login`', ], }) }) @@ -262,34 +232,31 @@ describe('thin config wrappers', () => { it('setConfig forwards the resolved path and config to cli-core writeConfig', async () => { mockWriteConfigCore.mockResolvedValueOnce(undefined) - await setConfig({ currentWorkspace: 7, authMode: 'read-write' }) + await setConfig({ currentWorkspace: 7 }) expect(mockWriteConfigCore).toHaveBeenCalledWith( '/tmp/cli-core-test/comms-cli/config.json', - { currentWorkspace: 7, authMode: 'read-write' }, + { currentWorkspace: 7 }, ) }) - it('setConfig dual-writes the channel field (camelCase + snake_case)', async () => { - // Older twist builds read `updateChannel`; cli-core reads - // `update_channel`. Both keys must hit disk during the overlap window. + it('setConfig translates updateChannel to update_channel on disk', async () => { mockWriteConfigCore.mockResolvedValueOnce(undefined) await setConfig({ updateChannel: 'pre-release', currentWorkspace: 3 }) expect(mockWriteConfigCore).toHaveBeenCalledWith( '/tmp/cli-core-test/comms-cli/config.json', { currentWorkspace: 3, - updateChannel: 'pre-release', update_channel: 'pre-release', }, ) }) - it('updateConfig delegates to cli-core (atomic) with dual-write translation', async () => { + it('updateConfig delegates to cli-core (atomic) with snake_case translation', async () => { mockUpdateConfigCore.mockResolvedValueOnce(undefined) await updateConfig({ updateChannel: 'stable' }) expect(mockUpdateConfigCore).toHaveBeenCalledWith( '/tmp/cli-core-test/comms-cli/config.json', - { updateChannel: 'stable', update_channel: 'stable' }, + { update_channel: 'stable' }, ) }) diff --git a/src/lib/config.ts b/src/lib/config.ts index feb2264..b7ebdba 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -9,12 +9,6 @@ import { CliError } from './errors.js' const APP_NAME = 'comms-cli' -/** - * Current on-disk schema version. Bumped when the persisted layout requires - * a one-time migration. One-way gate — no rollback. - */ -export const CONFIG_VERSION = 2 as const - /** * Resolve the canonical config path lazily. Computing on each call (instead of * caching at module load) keeps the path responsive to vitest's `vi.doMock` @@ -29,19 +23,11 @@ export type AuthMode = 'read-only' | 'read-write' | 'unknown' export type UpdateChannel = 'stable' | 'pre-release' const KNOWN_CONFIG_KEYS: ReadonlySet = new Set([ - 'token', - 'pendingSecureStoreClear', 'currentWorkspace', - 'authMode', - 'authScope', - 'authUserId', - 'authUserName', - 'updateChannel', - // Snake_case alias on disk for cli-core's update command; the in-memory - // `Config` exposes only `updateChannel` (see `fromDiskShape`/`toDiskShape`). + // cli-core's update command writes the channel under `update_channel`; + // the in-memory `Config` exposes it as `updateChannel`. 'update_channel', 'userSettings', - 'config_version', 'users', 'defaultUserId', ]) @@ -64,7 +50,7 @@ export interface UserSettings { } /** - * One row of the `users[]` array. `id` is the stringified numeric Twist user + * One row of the `users[]` array. `id` is the stringified numeric Comms user * id. `token` is a plaintext fallback persisted only when the keyring is * unavailable at write time. */ @@ -77,58 +63,34 @@ export type StoredUser = { } export interface Config { - config_version?: number users?: StoredUser[] defaultUserId?: string - - // Legacy single-user fields. Cleaned up by `migrateLegacyAuth`. - token?: string - pendingSecureStoreClear?: boolean - authMode?: AuthMode - authScope?: string - authUserId?: number - authUserName?: string - currentWorkspace?: number updateChannel?: UpdateChannel userSettings?: UserSettings } /** - * Read-seam translation: normalise the persisted shape to the in-memory - * `Config` shape. cli-core's update command writes the channel under - * `update_channel`; older twist builds wrote it under `updateChannel`. - * We accept both and expose only `updateChannel` to twist callers. - * - * `update_channel` wins if both are present (cli-core just wrote, so the - * snake_case value is freshest). Non-object inputs (a manually-edited - * config containing `null` or a primitive) are returned untouched so the - * downstream `Record` cast doesn't blow up on `in`. + * Read-seam translation: cli-core's update command writes the channel under + * `update_channel` (snake_case); we expose it as `updateChannel` (camelCase) + * to keep the in-memory shape idiomatic TS. Non-object inputs are returned + * untouched so the downstream cast doesn't blow up. */ function fromDiskShape(raw: unknown): Record { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return {} } const record = raw as Record - const hasCanonical = 'update_channel' in record - const hasLegacy = 'updateChannel' in record - if (!hasCanonical && !hasLegacy) return record - const { update_channel, updateChannel, ...rest } = record - const channel = hasCanonical ? update_channel : updateChannel - return channel === undefined ? rest : { ...rest, updateChannel: channel } + if (!('update_channel' in record)) return record + const { update_channel, ...rest } = record + return update_channel === undefined ? rest : { ...rest, updateChannel: update_channel } } -/** - * Write-seam translation: dual-write `updateChannel` and `update_channel` - * to disk when a channel is set, so older twist builds keep reading the - * camelCase key while cli-core's update command reads the snake_case key. - * Once all deployed twist versions read `update_channel`, drop the - * camelCase write (likely a release or two after this lands). - */ +/** Write-seam translation: camelCase `updateChannel` → snake_case `update_channel` on disk. */ function toDiskShape(config: Partial): Record { const { updateChannel, ...rest } = config if (updateChannel === undefined) return rest - return { ...rest, updateChannel, update_channel: updateChannel } + return { ...rest, update_channel: updateChannel } } /** @@ -164,14 +126,14 @@ export async function readConfigStrict(): Promise { throw new CliError( 'CONFIG_READ_FAILED', `Could not read config file ${path}: ${result.error.message}`, - ['Check file permissions, or run `tw doctor` to diagnose'], + ['Check file permissions, or run `cm doctor` to diagnose'], ) case 'invalid-json': throw new CliError( 'CONFIG_INVALID_JSON', `Config file at ${path} is not valid JSON: ${result.error.message}`, [ - 'Fix the JSON by hand, or delete the file and re-authenticate with `tw auth login`', + 'Fix the JSON by hand, or delete the file and re-authenticate with `cm auth login`', ], ) case 'invalid-shape': @@ -179,22 +141,21 @@ export async function readConfigStrict(): Promise { 'CONFIG_INVALID_SHAPE', `Config file at ${path} must contain a JSON object (got ${result.actual})`, [ - 'Fix the JSON by hand, or delete the file and re-authenticate with `tw auth login`', + 'Fix the JSON by hand, or delete the file and re-authenticate with `cm auth login`', ], ) } } -/** Thin wrapper around cli-core's `writeConfig`. Dual-writes the channel field. */ +/** Thin wrapper around cli-core's `writeConfig`. */ export async function setConfig(config: Config): Promise { await writeConfigCore(getConfigPath(), toDiskShape(config)) } /** * Atomic partial-write wrapper around cli-core's `updateConfig`. Preserves - * cli-core's read-merge-write atomicity so two concurrent `tw` processes - * can't lose each other's updates. Channel field is translated to disk - * shape (dual-written) before the merge. + * cli-core's read-merge-write atomicity so two concurrent `cm` processes + * can't lose each other's updates. */ export async function updateConfig(updates: Partial): Promise { await updateConfigCore>(getConfigPath(), toDiskShape(updates)) @@ -209,17 +170,6 @@ export function validateConfigForDoctor(config: Record): string } } - if (config.token !== undefined && typeof config.token !== 'string') { - issues.push('token must be a string') - } - - if ( - config.pendingSecureStoreClear !== undefined && - typeof config.pendingSecureStoreClear !== 'boolean' - ) { - issues.push('pendingSecureStoreClear must be a boolean') - } - if ( config.currentWorkspace !== undefined && (!Number.isInteger(config.currentWorkspace) || Number(config.currentWorkspace) <= 0) @@ -227,25 +177,6 @@ export function validateConfigForDoctor(config: Record): string issues.push('currentWorkspace must be a positive integer') } - if ( - config.authMode !== undefined && - (typeof config.authMode !== 'string' || !AUTH_MODES.has(config.authMode as AuthMode)) - ) { - issues.push('authMode must be one of: read-only, read-write, unknown') - } - - if (config.authScope !== undefined && typeof config.authScope !== 'string') { - issues.push('authScope must be a string') - } - - if ( - config.updateChannel !== undefined && - (typeof config.updateChannel !== 'string' || - !UPDATE_CHANNELS.has(config.updateChannel as UpdateChannel)) - ) { - issues.push('updateChannel must be one of: stable, pre-release') - } - if ( config.update_channel !== undefined && (typeof config.update_channel !== 'string' || @@ -254,13 +185,6 @@ export function validateConfigForDoctor(config: Record): string issues.push('update_channel must be one of: stable, pre-release') } - if ( - config.config_version !== undefined && - (typeof config.config_version !== 'number' || !Number.isInteger(config.config_version)) - ) { - issues.push('config_version must be an integer') - } - if (config.defaultUserId !== undefined && typeof config.defaultUserId !== 'string') { issues.push('defaultUserId must be a string') } diff --git a/src/lib/errors.ts b/src/lib/errors.ts index e8a5ca1..6d38d04 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -11,7 +11,6 @@ export type { ErrorType } from '@doist/cli-core' export type ErrorCode = // Auth & permissions | 'AUTH_FAILED' - | 'AUTH_MIGRATION_PENDING' | 'INSUFFICIENT_SCOPE' | 'INVALID_TOKEN' | 'NO_TOKEN' diff --git a/src/lib/global-args.test.ts b/src/lib/global-args.test.ts index 2a7826f..dfa0534 100644 --- a/src/lib/global-args.test.ts +++ b/src/lib/global-args.test.ts @@ -60,25 +60,25 @@ describe('parseGlobalArgs', () => { describe('--progress-jsonl', () => { it('detects --progress-jsonl without path', () => { - const result = parseGlobalArgs(['node', 'tw', '--progress-jsonl']) + const result = parseGlobalArgs(['node', 'cm', '--progress-jsonl']) expect(result.progressJsonl).toBe(true) expect(result.progressJsonlPath).toBeUndefined() }) it('detects --progress-jsonl=path', () => { - const result = parseGlobalArgs(['node', 'tw', '--progress-jsonl=/tmp/out.jsonl']) + const result = parseGlobalArgs(['node', 'cm', '--progress-jsonl=/tmp/out.jsonl']) expect(result.progressJsonl).toBe('/tmp/out.jsonl') expect(result.progressJsonlPath).toBe('/tmp/out.jsonl') }) it('detects --progress-jsonl path as separate arg (Comms re-adds the space form cli-core drops)', () => { - const result = parseGlobalArgs(['node', 'tw', '--progress-jsonl', '/tmp/out.jsonl']) + const result = parseGlobalArgs(['node', 'cm', '--progress-jsonl', '/tmp/out.jsonl']) expect(result.progressJsonl).toBe('/tmp/out.jsonl') expect(result.progressJsonlPath).toBe('/tmp/out.jsonl') }) it('does not treat next flag as path', () => { - const result = parseGlobalArgs(['node', 'tw', '--progress-jsonl', '--json']) + const result = parseGlobalArgs(['node', 'cm', '--progress-jsonl', '--json']) expect(result.progressJsonl).toBe(true) expect(result.progressJsonlPath).toBeUndefined() }) @@ -89,7 +89,7 @@ describe('parseGlobalArgs', () => { it('=path then space form: space form wins', () => { const result = parseGlobalArgs([ 'node', - 'tw', + 'cm', '--progress-jsonl=/tmp/first', '--progress-jsonl', '/tmp/second', @@ -101,7 +101,7 @@ describe('parseGlobalArgs', () => { it('space form then =path: =path wins', () => { const result = parseGlobalArgs([ 'node', - 'tw', + 'cm', '--progress-jsonl', '/tmp/first', '--progress-jsonl=/tmp/second', @@ -113,7 +113,7 @@ describe('parseGlobalArgs', () => { it('path then bare: bare reverts to true (no path)', () => { const result = parseGlobalArgs([ 'node', - 'tw', + 'cm', '--progress-jsonl', '/tmp/first', '--progress-jsonl', @@ -125,7 +125,7 @@ describe('parseGlobalArgs', () => { it('repeated =path forms: last wins', () => { const result = parseGlobalArgs([ 'node', - 'tw', + 'cm', '--progress-jsonl=/tmp/first', '--progress-jsonl=/tmp/second', ]) @@ -141,7 +141,7 @@ describe('cached singleton', () => { beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'tw'] + process.argv = ['node', 'cm'] }) afterEach(() => { @@ -150,16 +150,16 @@ describe('cached singleton', () => { }) it('returns fresh results after resetGlobalArgs()', () => { - process.argv = ['node', 'tw'] + process.argv = ['node', 'cm'] expect(isProgressJsonlEnabled()).toBe(false) resetGlobalArgs() - process.argv = ['node', 'tw', '--progress-jsonl'] + process.argv = ['node', 'cm', '--progress-jsonl'] expect(isProgressJsonlEnabled()).toBe(true) }) it('exposes the resolved path via getProgressJsonlPath()', () => { - process.argv = ['node', 'tw', '--progress-jsonl', '/tmp/out.jsonl'] + process.argv = ['node', 'cm', '--progress-jsonl', '/tmp/out.jsonl'] expect(getProgressJsonlPath()).toBe('/tmp/out.jsonl') }) }) @@ -169,13 +169,13 @@ describe('isAccessible', () => { beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'tw'] - delete process.env.TW_ACCESSIBLE + process.argv = ['node', 'cm'] + delete process.env.CM_ACCESSIBLE }) afterEach(() => { process.argv = originalArgv - delete process.env.TW_ACCESSIBLE + delete process.env.CM_ACCESSIBLE resetGlobalArgs() }) @@ -183,20 +183,20 @@ describe('isAccessible', () => { expect(isAccessible()).toBe(false) }) - it('returns true when TW_ACCESSIBLE=1', () => { - process.env.TW_ACCESSIBLE = '1' + it('returns true when CM_ACCESSIBLE=1', () => { + process.env.CM_ACCESSIBLE = '1' expect(isAccessible()).toBe(true) }) - it('returns false when TW_ACCESSIBLE is set to other values', () => { - process.env.TW_ACCESSIBLE = '0' + it('returns false when CM_ACCESSIBLE is set to other values', () => { + process.env.CM_ACCESSIBLE = '0' expect(isAccessible()).toBe(false) - process.env.TW_ACCESSIBLE = 'true' + process.env.CM_ACCESSIBLE = 'true' expect(isAccessible()).toBe(false) }) it('returns true when --accessible is in argv', () => { - process.argv = ['node', 'tw', '--accessible'] + process.argv = ['node', 'cm', '--accessible'] resetGlobalArgs() expect(isAccessible()).toBe(true) }) @@ -209,7 +209,7 @@ describe('isNonInteractive', () => { beforeEach(() => { originalIsTTY = process.stdin.isTTY resetGlobalArgs() - process.argv = ['node', 'tw'] + process.argv = ['node', 'cm'] }) afterEach(() => { @@ -242,7 +242,7 @@ describe('isNonInteractive', () => { value: true, configurable: true, }) - process.argv = ['node', 'tw', '--non-interactive'] + process.argv = ['node', 'cm', '--non-interactive'] resetGlobalArgs() expect(isNonInteractive()).toBe(true) }) @@ -252,13 +252,13 @@ describe('isNonInteractive', () => { value: undefined, configurable: true, }) - process.argv = ['node', 'tw', '--interactive'] + process.argv = ['node', 'cm', '--interactive'] resetGlobalArgs() expect(isNonInteractive()).toBe(false) }) it('--interactive overrides --non-interactive', () => { - process.argv = ['node', 'tw', '--non-interactive', '--interactive'] + process.argv = ['node', 'cm', '--non-interactive', '--interactive'] resetGlobalArgs() expect(isNonInteractive()).toBe(false) }) @@ -270,7 +270,7 @@ describe('includePrivateChannels', () => { beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'tw'] + process.argv = ['node', 'cm'] delete process.env.COMMS_INCLUDE_PRIVATE_CHANNELS }) @@ -289,7 +289,7 @@ describe('includePrivateChannels', () => { }) it('returns true when --include-private-channels is in argv', () => { - process.argv = ['node', 'tw', '--include-private-channels'] + process.argv = ['node', 'cm', '--include-private-channels'] resetGlobalArgs() expect(includePrivateChannels()).toBe(true) }) @@ -317,14 +317,14 @@ describe('shouldDisableSpinner', () => { beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'tw'] - delete process.env.TW_SPINNER + process.argv = ['node', 'cm'] + delete process.env.CM_SPINNER delete process.env.CI }) afterEach(() => { process.argv = originalArgv - delete process.env.TW_SPINNER + delete process.env.CM_SPINNER delete process.env.CI resetGlobalArgs() }) @@ -333,8 +333,8 @@ describe('shouldDisableSpinner', () => { expect(shouldDisableSpinner()).toBe(false) }) - it('returns true when TW_SPINNER=false', () => { - process.env.TW_SPINNER = 'false' + it('returns true when CM_SPINNER=false', () => { + process.env.CM_SPINNER = 'false' expect(shouldDisableSpinner()).toBe(true) }) @@ -352,11 +352,11 @@ describe('shouldDisableSpinner', () => { }) it.each([ - ['--json', ['node', 'tw', '--json']], - ['--ndjson', ['node', 'tw', '--ndjson']], - ['--no-spinner', ['node', 'tw', '--no-spinner']], - ['--progress-jsonl', ['node', 'tw', '--progress-jsonl']], - ['--non-interactive', ['node', 'tw', '--non-interactive']], + ['--json', ['node', 'cm', '--json']], + ['--ndjson', ['node', 'cm', '--ndjson']], + ['--no-spinner', ['node', 'cm', '--no-spinner']], + ['--progress-jsonl', ['node', 'cm', '--progress-jsonl']], + ['--non-interactive', ['node', 'cm', '--non-interactive']], ])('returns true with %s flag', (_flag, argv) => { process.argv = argv resetGlobalArgs() diff --git a/src/lib/global-args.ts b/src/lib/global-args.ts index 3cfa53d..9f6d6c7 100644 --- a/src/lib/global-args.ts +++ b/src/lib/global-args.ts @@ -1,14 +1,14 @@ /** * Per-CLI extension of `@doist/cli-core`'s global-args parser. * - * Layers twist's `--include-private-channels`, `--non-interactive`, + * 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 twist actually registers + * the subset of cli-core's canonical shape that comms actually registers * with Commander (`--json`, `--ndjson`, `--accessible`, `--no-spinner`, * `--progress-jsonl[=path]`). * * cli-core's parser also surfaces `quiet` and `verbose` from argv, but - * twist does not register `--quiet` or `--verbose` globally (Commander + * comms does not register `--quiet` or `--verbose` globally (Commander * would reject them) — so we drop them from the exported shape to avoid * the type/API leak where helpers believe the binary supports them. */ @@ -21,8 +21,8 @@ import { parseGlobalArgs as parseCoreGlobalArgs, } from '@doist/cli-core' -type TwSpecificFlags = { - /** Bare/string/false — see `TwLocalFlags.progressJsonl` for semantics. */ +type CommsSpecificFlags = { + /** Bare/string/false — see `CommsLocalFlags.progressJsonl` for semantics. */ progressJsonl: string | true | false /** Resolved path for `--progress-jsonl` (any form). `undefined` when bare or absent. */ progressJsonlPath: string | undefined @@ -32,46 +32,43 @@ type TwSpecificFlags = { } /** - * Public shape exposed to twist callers. Drops cli-core's `quiet` and - * `verbose` because twist does not register `--quiet` / `--verbose` with + * Public shape exposed to comms callers. Drops cli-core's `quiet` and + * `verbose` because comms does not register `--quiet` / `--verbose` with * Commander — exposing them in the type would lie about what the binary * supports. */ -export type TwGlobalArgs = Pick< +export type GlobalArgs = Pick< CoreGlobalArgs, 'json' | 'ndjson' | 'accessible' | 'noSpinner' | 'user' > & - TwSpecificFlags - -/** Back-compat alias — pre-cli-core twist code imported `GlobalArgs` from this module. */ -export type GlobalArgs = TwGlobalArgs + CommsSpecificFlags /** * Internal store shape — keeps the full cli-core surface so the shared * `createGlobalArgsStore` / `createAccessibleGate` / `createSpinnerGate` - * helpers still typecheck against `T extends GlobalArgs`. Twist callers - * see the narrower {@link TwGlobalArgs} via `parseGlobalArgs`. + * helpers still typecheck against `T extends GlobalArgs`. Comms callers + * see the narrower {@link GlobalArgs} via `parseGlobalArgs`. */ -type FullArgs = CoreGlobalArgs & TwSpecificFlags +type FullArgs = CoreGlobalArgs & CommsSpecificFlags -type TwLocalFlags = { +type CommsLocalFlags = { includePrivateChannels: boolean nonInteractive: boolean interactive: boolean /** * Resolved value for `--progress-jsonl` across all three forms (bare, * `=path`, space-separated ``). `false` = absent, `true` = bare, - * string = path. Twist parses this locally — and ignores cli-core's + * string = path. Comms parses this locally — and ignores cli-core's * `progressJsonl` field — so that "last occurrence wins" stays correct - * when the forms are mixed (`tw --progress-jsonl=/a --progress-jsonl /b` + * when the forms are mixed (`cm --progress-jsonl=/a --progress-jsonl /b` * → `/b`). cli-core deliberately drops the space form cross-CLI because - * it can swallow positionals (`td task add --progress-jsonl "Buy milk"`); - * twist re-adds it because the flag is global, not subcommand-attached. + * it can swallow positionals; comms re-adds it because the flag is + * global, not subcommand-attached. */ progressJsonl: string | true | false } -function parseTwLocalFlags(argv: string[]): TwLocalFlags { +function parseCommsLocalFlags(argv: string[]): CommsLocalFlags { let includePrivate = false let nonInteractive = false let interactive = false @@ -110,7 +107,7 @@ function parseTwLocalFlags(argv: string[]): TwLocalFlags { function parseFullArgs(argv?: string[]): FullArgs { const args = argv ?? process.argv const base = parseCoreGlobalArgs(args) - const local = parseTwLocalFlags(args) + const local = parseCommsLocalFlags(args) return { ...base, @@ -126,10 +123,10 @@ function parseFullArgs(argv?: string[]): FullArgs { /** * Parse well-known global flags from an argv array. Pure — pass an explicit * array for testing, or omit to read `process.argv`. Returns the narrowed - * twist surface; cli-core's `quiet` and `verbose` are intentionally - * dropped (see {@link TwGlobalArgs}). + * comms surface; cli-core's `quiet` and `verbose` are intentionally + * dropped (see {@link GlobalArgs}). */ -export function parseGlobalArgs(argv?: string[]): TwGlobalArgs { +export function parseGlobalArgs(argv?: string[]): GlobalArgs { const { quiet: _quiet, verbose: _verbose, ...rest } = parseFullArgs(argv) return rest } @@ -151,7 +148,7 @@ export function isNdjsonMode(): boolean { return store.get().ndjson } -/** Pre-subcommand `tw --user ` (see `stripUserFlag` in `src/index.ts`). */ +/** Pre-subcommand `cm --user ` (see `stripUserFlag` in `src/index.ts`). */ export function getRequestedUserRef(): string | undefined { return store.get().user } @@ -164,7 +161,7 @@ export function isNonInteractive(): boolean { } export function includePrivateChannels(): boolean { - const envVal = process.env.TWIST_INCLUDE_PRIVATE_CHANNELS + const envVal = process.env.COMMS_INCLUDE_PRIVATE_CHANNELS if (envVal === '1' || envVal === 'true') { return true } @@ -180,12 +177,12 @@ export function getProgressJsonlPath(): string | undefined { } export const isAccessible = createAccessibleGate({ - envVar: 'TW_ACCESSIBLE', + envVar: 'CM_ACCESSIBLE', getArgs: store.get, }) export const shouldDisableSpinner = createSpinnerGate({ - envVar: 'TW_SPINNER', + envVar: 'CM_SPINNER', getArgs: store.get, extraTriggers: () => store.get().nonInteractive, }) diff --git a/src/lib/input.test.ts b/src/lib/input.test.ts index d5a39b5..8a32c53 100644 --- a/src/lib/input.test.ts +++ b/src/lib/input.test.ts @@ -9,7 +9,7 @@ describe('isNonInteractive', () => { beforeEach(() => { originalIsTTY = process.stdin.isTTY resetGlobalArgs() - process.argv = ['node', 'tw', 'thread', 'create', '100', 'Title'] + process.argv = ['node', 'cm', 'thread', 'create', '100', 'Title'] }) afterEach(() => { @@ -75,7 +75,7 @@ describe('openEditor', () => { beforeEach(() => { originalIsTTY = process.stdin.isTTY resetGlobalArgs() - process.argv = ['node', 'tw'] + process.argv = ['node', 'cm'] }) afterEach(() => { diff --git a/src/lib/markdown.test.ts b/src/lib/markdown.test.ts index 2515a5e..fa36c90 100644 --- a/src/lib/markdown.test.ts +++ b/src/lib/markdown.test.ts @@ -20,8 +20,8 @@ describe('markdown', () => { expect(result).toContain('•') }) - it('preprocesses twist-mention links into @mentions', async () => { - const result = await renderMarkdown('hello [Alice](twist-mention://12345)') + it('preprocesses comms-mention links into @mentions', async () => { + const result = await renderMarkdown('hello [Alice](comms-mention://12345)') expect(result).toContain('@Alice') }) diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 6403475..e588254 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -6,7 +6,7 @@ import { let preloadPromise: Promise | null = null function preprocessMentions(content: string): string { - return content.replace(/\[([^\]]+)\]\((twist-mention:\/\/\d+)\)/g, '[@$1]($2)') + return content.replace(/\[([^\]]+)\]\((comms-mention:\/\/\d+)\)/g, '[@$1]($2)') } export async function preloadMarkdown(): Promise { diff --git a/src/lib/migrate-auth.test.ts b/src/lib/migrate-auth.test.ts deleted file mode 100644 index af39421..0000000 --- a/src/lib/migrate-auth.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - migrateLegacyAuth: vi.fn(), - twistApiCtor: vi.fn(), - getSessionUserMock: vi.fn(), - getConfig: vi.fn(), - updateConfig: vi.fn(), -})) - -vi.mock('@doist/cli-core/auth', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, migrateLegacyAuth: mocks.migrateLegacyAuth } -}) - -vi.mock('@doist/comms-sdk', () => ({ - CommsApi: mocks.twistApiCtor.mockImplementation(function (this: object, _token: string) { - Object.assign(this, { users: { getSessionUser: mocks.getSessionUserMock } }) - }), -})) - -vi.mock('./config.js', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - getConfig: mocks.getConfig, - updateConfig: mocks.updateConfig, - } -}) - -import type { MigrateLegacyAuthOptions } from '@doist/cli-core/auth' -import { runMigrateLegacyAuth } from './migrate-auth.js' - -type Opts = MigrateLegacyAuthOptions<{ - id: string - label: string - authMode: 'read-only' | 'read-write' | 'unknown' - authScope: string -}> - -/** Trigger the wrapper and hand back the options it passed to cli-core. */ -async function captureOptions(): Promise { - await runMigrateLegacyAuth({ silent: true }) - return mocks.migrateLegacyAuth.mock.calls.at(-1)![0] as Opts -} - -describe('runMigrateLegacyAuth', () => { - beforeEach(() => { - mocks.migrateLegacyAuth.mockReset().mockResolvedValue({ status: 'no-legacy-state' }) - mocks.twistApiCtor.mockClear() - mocks.getSessionUserMock.mockReset() - mocks.getConfig.mockReset() - mocks.updateConfig.mockReset().mockResolvedValue(undefined) - }) - - it('passes comms-cli wiring to cli-core: serviceName, legacy api-token slot, silent flag, no accountForUser override', async () => { - const options = await captureOptions() - expect(options.serviceName).toBe('comms-cli') - expect(options.legacyAccount).toBe('api-token') - expect(options.accountForUser).toBeUndefined() - expect(options.silent).toBe(true) - }) - - it('hasMigrated returns true once config_version reaches 2 (one-way gate; >= 2 so future bumps still count)', async () => { - mocks.getConfig.mockResolvedValueOnce({ config_version: 2 }) - mocks.getConfig.mockResolvedValueOnce({ config_version: 3 }) - mocks.getConfig.mockResolvedValueOnce({ config_version: 1 }) - mocks.getConfig.mockResolvedValueOnce({}) - const options = await captureOptions() - - expect(await options.hasMigrated()).toBe(true) - expect(await options.hasMigrated()).toBe(true) - expect(await options.hasMigrated()).toBe(false) - expect(await options.hasMigrated()).toBe(false) - }) - - it('markMigrated writes config_version = 2 (decoupled from the exported CONFIG_VERSION)', async () => { - const options = await captureOptions() - await options.markMigrated() - expect(mocks.updateConfig).toHaveBeenCalledWith({ config_version: 2 }) - }) - - it('loadLegacyPlaintextToken returns trimmed config.token, or null when blank/absent', async () => { - const options = await captureOptions() - - mocks.getConfig.mockResolvedValueOnce({ token: ' tk_legacy ' }) - expect(await options.loadLegacyPlaintextToken()).toBe('tk_legacy') - - mocks.getConfig.mockResolvedValueOnce({ token: ' ' }) - expect(await options.loadLegacyPlaintextToken()).toBeNull() - - mocks.getConfig.mockResolvedValueOnce({}) - expect(await options.loadLegacyPlaintextToken()).toBeNull() - }) - - it('identifyAccount resolves a CommsAccount from a raw CommsApi + v1 auth metadata on disk', async () => { - mocks.getSessionUserMock.mockResolvedValue({ id: 42, name: 'Ada' }) - mocks.getConfig.mockResolvedValue({ authMode: 'read-write', authScope: 'user:read' }) - const options = await captureOptions() - - expect(await options.identifyAccount('tk_legacy')).toEqual({ - id: '42', - label: 'Ada', - authMode: 'read-write', - authScope: 'user:read', - }) - expect(mocks.twistApiCtor).toHaveBeenCalledWith('tk_legacy') - }) - - it('identifyAccount runs the API call and the local getConfig() concurrently', async () => { - let resolveApi: (value: { id: number; name: string }) => void = () => {} - const apiPromise = new Promise<{ id: number; name: string }>((res) => { - resolveApi = res - }) - mocks.getSessionUserMock.mockReturnValueOnce(apiPromise) - mocks.getConfig.mockResolvedValueOnce({}) - const options = await captureOptions() - - const identifyPromise = options.identifyAccount('tk_legacy') - expect(mocks.getConfig).toHaveBeenCalledTimes(1) // already in flight - resolveApi({ id: 7, name: 'Carl' }) - - // Empty config → authMode / authScope default to 'unknown' / ''. - await expect(identifyPromise).resolves.toEqual({ - id: '7', - label: 'Carl', - authMode: 'unknown', - authScope: '', - }) - }) - - it('cleanupLegacyConfig clears every v1 flat field in a single updateConfig call', async () => { - const options = await captureOptions() - await options.cleanupLegacyConfig!() - - expect(mocks.updateConfig).toHaveBeenCalledWith({ - token: undefined, - authMode: undefined, - authScope: undefined, - authUserId: undefined, - authUserName: undefined, - pendingSecureStoreClear: undefined, - }) - }) -}) diff --git a/src/lib/migrate-auth.ts b/src/lib/migrate-auth.ts deleted file mode 100644 index 15ee653..0000000 --- a/src/lib/migrate-auth.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { type MigrateAuthResult, migrateLegacyAuth } from '@doist/cli-core/auth' -import { CommsApi } from '@doist/comms-sdk' -import { LEGACY_KEYRING_ACCOUNT, SECURE_STORE_SERVICE } from './auth-constants.js' -import type { CommsAccount } from './auth-provider.js' -import { getConfig, updateConfig } from './config.js' -import { toCommsAccount } from './comms-account.js' -import { createCommsUserRecordStore } from './user-records.js' - -/** - * Pinned to this migration's target schema. Decoupled from the exported - * `CONFIG_VERSION` so a future bump doesn't make this helper re-run for - * users who are already on v2 or beyond. - */ -const V2_SCHEMA_VERSION = 2 - -/** - * One-time migration of v1 auth state into the v2 `users[]` shape. Called - * by postinstall and by the lazy hook in `createCommsTokenStore`. Idempotent - * via the `config_version` marker. - * - * Uses raw `CommsApi` rather than `createWrappedCommsClient` to keep this - * module out of the runtime auth/token-store import graph. - */ -export async function runMigrateLegacyAuth( - options: { silent: boolean } = { silent: true }, -): Promise> { - return migrateLegacyAuth({ - serviceName: SECURE_STORE_SERVICE, - legacyAccount: LEGACY_KEYRING_ACCOUNT, - userRecords: createCommsUserRecordStore(), - hasMigrated: async () => { - const config = await getConfig() - return (config.config_version ?? 0) >= V2_SCHEMA_VERSION - }, - markMigrated: async () => { - await updateConfig({ config_version: V2_SCHEMA_VERSION }) - }, - loadLegacyPlaintextToken: async () => { - const config = await getConfig() - return config.token?.trim() || null - }, - identifyAccount: async (token) => { - const [user, config] = await Promise.all([ - new CommsApi(token).users.getSessionUser(), - getConfig(), - ]) - return toCommsAccount(user, { - authMode: config.authMode, - authScope: config.authScope, - }) - }, - cleanupLegacyConfig: async () => { - await updateConfig({ - token: undefined, - authMode: undefined, - authScope: undefined, - authUserId: undefined, - authUserName: undefined, - pendingSecureStoreClear: undefined, - }) - }, - silent: options.silent, - logPrefix: 'comms-cli', - }) -} diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts index b5b7318..dcf888e 100644 --- a/src/lib/output.test.ts +++ b/src/lib/output.test.ts @@ -10,11 +10,11 @@ describe('isAccessible', () => { beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'tw'] + process.argv = ['node', 'cm'] }) afterEach(() => { - delete process.env.TW_ACCESSIBLE + delete process.env.CM_ACCESSIBLE process.argv = originalArgv resetGlobalArgs() }) @@ -23,20 +23,20 @@ describe('isAccessible', () => { expect(isAccessible()).toBe(false) }) - it('returns true when TW_ACCESSIBLE=1', () => { - process.env.TW_ACCESSIBLE = '1' + it('returns true when CM_ACCESSIBLE=1', () => { + process.env.CM_ACCESSIBLE = '1' expect(isAccessible()).toBe(true) }) - it('returns false when TW_ACCESSIBLE is set to other values', () => { - process.env.TW_ACCESSIBLE = '0' + it('returns false when CM_ACCESSIBLE is set to other values', () => { + process.env.CM_ACCESSIBLE = '0' expect(isAccessible()).toBe(false) - process.env.TW_ACCESSIBLE = 'true' + process.env.CM_ACCESSIBLE = 'true' expect(isAccessible()).toBe(false) }) it('returns true when --accessible is in argv', () => { - process.argv = ['node', 'tw', '--accessible'] + process.argv = ['node', 'cm', '--accessible'] resetGlobalArgs() expect(isAccessible()).toBe(true) }) diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 24bccdd..792f05e 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -2,7 +2,7 @@ import { getAuthMetadata } from './auth.js' import { CliError } from './errors.js' export const READ_ONLY_ERROR_MESSAGE = - 'This CLI is authenticated in read-only mode. Re-run `tw auth login` without --read-only to enable write operations.' + 'This CLI is authenticated in read-only mode. Re-run `cm auth login` without --read-only to enable write operations.' /** * Known read-only API method paths. Any method not in this set is assumed to be mutating. @@ -39,7 +39,7 @@ export async function ensureWriteAllowed(): Promise { const metadata = await getAuthMetadata() if (metadata.authMode === 'read-only') { throw new CliError('READ_ONLY', READ_ONLY_ERROR_MESSAGE, [ - 'Re-run: tw auth login (without --read-only)', + 'Re-run: cm auth login (without --read-only)', ]) } } diff --git a/src/lib/progress.test.ts b/src/lib/progress.test.ts index 019dc80..a45d333 100644 --- a/src/lib/progress.test.ts +++ b/src/lib/progress.test.ts @@ -57,20 +57,20 @@ describe('ProgressTracker', () => { describe('initialization and enabling', () => { it.each([ - ['disabled by default', ['node', 'tw', 'threads'], false], + ['disabled by default', ['node', 'cm', 'threads'], false], [ 'enabled with --progress-jsonl flag', - ['node', 'tw', 'threads', '--progress-jsonl'], + ['node', 'cm', 'threads', '--progress-jsonl'], true, ], [ 'enabled with --progress-jsonl=path flag', - ['node', 'tw', 'threads', '--progress-jsonl=/tmp/progress.jsonl'], + ['node', 'cm', 'threads', '--progress-jsonl=/tmp/progress.jsonl'], true, ], [ 'enabled with --progress-jsonl path as separate arg', - ['node', 'tw', 'threads', '--progress-jsonl', '/tmp/progress.jsonl'], + ['node', 'cm', 'threads', '--progress-jsonl', '/tmp/progress.jsonl'], true, ], ])('should be %s', (_description, argv, expectedEnabled) => { @@ -82,7 +82,7 @@ describe('ProgressTracker', () => { describe('output destinations', () => { it('should output to stderr by default', () => { - process.argv = ['node', 'tw', 'threads', '--progress-jsonl'] + process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] const tracker = new ProgressTracker() tracker.emit({ type: 'start', command: 'threads' }) @@ -93,7 +93,7 @@ describe('ProgressTracker', () => { }) it('should create file when path is provided with equals', () => { - process.argv = ['node', 'tw', 'threads', '--progress-jsonl=/tmp/progress.jsonl'] + process.argv = ['node', 'cm', 'threads', '--progress-jsonl=/tmp/progress.jsonl'] const tracker = new ProgressTracker() expect(fs.createWriteStream).toHaveBeenCalledWith('/tmp/progress.jsonl', { flags: 'a' }) @@ -103,14 +103,14 @@ describe('ProgressTracker', () => { }) it('should create file when path is provided as separate arg', () => { - process.argv = ['node', 'tw', 'threads', '--progress-jsonl', '/tmp/progress.jsonl'] + process.argv = ['node', 'cm', 'threads', '--progress-jsonl', '/tmp/progress.jsonl'] const _tracker = new ProgressTracker() expect(fs.createWriteStream).toHaveBeenCalledWith('/tmp/progress.jsonl', { flags: 'a' }) }) it('should fall back to stderr if file creation fails', () => { - process.argv = ['node', 'tw', 'threads', '--progress-jsonl=/invalid/path'] + process.argv = ['node', 'cm', 'threads', '--progress-jsonl=/invalid/path'] vi.mocked(fs.createWriteStream).mockImplementation(() => { throw new Error('Permission denied') }) @@ -133,7 +133,7 @@ describe('ProgressTracker', () => { let tracker: ProgressTracker beforeEach(() => { - process.argv = ['node', 'tw', 'threads', '--progress-jsonl'] + process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] tracker = new ProgressTracker() }) @@ -210,7 +210,7 @@ describe('ProgressTracker', () => { it('should not emit events when disabled', () => { resetProgressTracker() resetGlobalArgs() - process.argv = ['node', 'tw', 'threads'] // No --progress-jsonl flag + process.argv = ['node', 'cm', 'threads'] // No --progress-jsonl flag const disabledTracker = new ProgressTracker() disabledTracker.emitStart('threads') @@ -244,7 +244,7 @@ describe('ProgressTracker', () => { describe('cleanup', () => { it('should close file stream when calling close()', () => { - process.argv = ['node', 'tw', 'threads', '--progress-jsonl=/tmp/progress.jsonl'] + process.argv = ['node', 'cm', 'threads', '--progress-jsonl=/tmp/progress.jsonl'] const tracker = new ProgressTracker() tracker.close() @@ -254,7 +254,7 @@ describe('ProgressTracker', () => { }) it('should handle close() when using stderr', () => { - process.argv = ['node', 'tw', 'threads', '--progress-jsonl'] + process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] const tracker = new ProgressTracker() // Should not throw @@ -281,7 +281,7 @@ describe('global progress tracker', () => { }) it('should return singleton instance', () => { - process.argv = ['node', 'tw', 'threads', '--progress-jsonl'] + process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] const tracker1 = getProgressTracker() const tracker2 = getProgressTracker() @@ -290,7 +290,7 @@ describe('global progress tracker', () => { }) it('should create new instance after reset', () => { - process.argv = ['node', 'tw', 'threads', '--progress-jsonl'] + process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] const tracker1 = getProgressTracker() resetProgressTracker() @@ -301,14 +301,14 @@ describe('global progress tracker', () => { it('should respect argv changes between instances', () => { // First instance - disabled - process.argv = ['node', 'tw', 'threads'] + process.argv = ['node', 'cm', 'threads'] const tracker1 = getProgressTracker() expect(tracker1.isEnabled()).toBe(false) // Reset and create new instance with flag resetProgressTracker() resetGlobalArgs() - process.argv = ['node', 'tw', 'threads', '--progress-jsonl'] + process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] const tracker2 = getProgressTracker() expect(tracker2.isEnabled()).toBe(true) }) @@ -341,10 +341,10 @@ describe('edge cases and integration', () => { }) it.each([ - ['flag in middle of arguments', ['node', 'tw', '--progress-jsonl', 'threads', '--json']], - ['flag at end of arguments', ['node', 'tw', 'threads', '--json', '--progress-jsonl']], - ['flag with empty path argument', ['node', 'tw', 'threads', '--progress-jsonl', '']], - ['flag followed by another flag', ['node', 'tw', 'threads', '--progress-jsonl', '--json']], + ['flag in middle of arguments', ['node', 'cm', '--progress-jsonl', 'threads', '--json']], + ['flag at end of arguments', ['node', 'cm', 'threads', '--json', '--progress-jsonl']], + ['flag with empty path argument', ['node', 'cm', 'threads', '--progress-jsonl', '']], + ['flag followed by another flag', ['node', 'cm', 'threads', '--progress-jsonl', '--json']], ])('should handle %s', (_description, argv) => { process.argv = argv const tracker = new ProgressTracker() @@ -354,7 +354,7 @@ describe('edge cases and integration', () => { it('should handle multiple progress-jsonl flags (last one wins)', () => { process.argv = [ 'node', - 'tw', + 'cm', '--progress-jsonl=/tmp/first', '--progress-jsonl=/tmp/second', 'threads', diff --git a/src/lib/public-channels.test.ts b/src/lib/public-channels.test.ts index 5111941..f95e8ea 100644 --- a/src/lib/public-channels.test.ts +++ b/src/lib/public-channels.test.ts @@ -12,7 +12,7 @@ import { getPublicChannelIds, } from './public-channels.js' -const mockGetTwistClient = vi.mocked(getCommsClient) +const mockGetCommsClient = vi.mocked(getCommsClient) function makeMockChannels( channels: Array<{ id: number; public: boolean }>, @@ -26,20 +26,20 @@ function makeMockChannels( describe('includePrivateChannels', () => { const originalArgv = [...process.argv] - const originalEnv = process.env.TWIST_INCLUDE_PRIVATE_CHANNELS + const originalEnv = process.env.COMMS_INCLUDE_PRIVATE_CHANNELS beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'tw'] - delete process.env.TWIST_INCLUDE_PRIVATE_CHANNELS + process.argv = ['node', 'cm'] + delete process.env.COMMS_INCLUDE_PRIVATE_CHANNELS }) afterEach(() => { process.argv = originalArgv if (originalEnv !== undefined) { - process.env.TWIST_INCLUDE_PRIVATE_CHANNELS = originalEnv + process.env.COMMS_INCLUDE_PRIVATE_CHANNELS = originalEnv } else { - delete process.env.TWIST_INCLUDE_PRIVATE_CHANNELS + delete process.env.COMMS_INCLUDE_PRIVATE_CHANNELS } resetGlobalArgs() }) @@ -49,29 +49,29 @@ describe('includePrivateChannels', () => { }) it('returns true when --include-private-channels is in argv', () => { - process.argv = ['node', 'tw', 'channels', '--include-private-channels'] + process.argv = ['node', 'cm', 'channels', '--include-private-channels'] resetGlobalArgs() expect(includePrivateChannels()).toBe(true) }) - it('returns true when TWIST_INCLUDE_PRIVATE_CHANNELS=1', () => { - process.env.TWIST_INCLUDE_PRIVATE_CHANNELS = '1' + it('returns true when COMMS_INCLUDE_PRIVATE_CHANNELS=1', () => { + process.env.COMMS_INCLUDE_PRIVATE_CHANNELS = '1' expect(includePrivateChannels()).toBe(true) }) - it('returns true when TWIST_INCLUDE_PRIVATE_CHANNELS=true', () => { - process.env.TWIST_INCLUDE_PRIVATE_CHANNELS = 'true' + it('returns true when COMMS_INCLUDE_PRIVATE_CHANNELS=true', () => { + process.env.COMMS_INCLUDE_PRIVATE_CHANNELS = 'true' expect(includePrivateChannels()).toBe(true) }) it('returns false for other env values', () => { - process.env.TWIST_INCLUDE_PRIVATE_CHANNELS = '0' + process.env.COMMS_INCLUDE_PRIVATE_CHANNELS = '0' expect(includePrivateChannels()).toBe(false) - process.env.TWIST_INCLUDE_PRIVATE_CHANNELS = 'false' + process.env.COMMS_INCLUDE_PRIVATE_CHANNELS = 'false' expect(includePrivateChannels()).toBe(false) - process.env.TWIST_INCLUDE_PRIVATE_CHANNELS = '' + process.env.COMMS_INCLUDE_PRIVATE_CHANNELS = '' expect(includePrivateChannels()).toBe(false) }) }) @@ -82,7 +82,7 @@ describe('getPublicChannelIds', () => { }) it('returns only public channel IDs', async () => { - mockGetTwistClient.mockImplementation(() => + mockGetCommsClient.mockImplementation(() => makeMockChannels([ { id: 1, public: true }, { id: 2, public: false }, @@ -96,7 +96,7 @@ describe('getPublicChannelIds', () => { it('caches results per workspace', async () => { const getChannels = vi.fn().mockResolvedValue([{ id: 1, public: true }]) - mockGetTwistClient.mockResolvedValue({ + mockGetCommsClient.mockResolvedValue({ channels: { getChannels }, } as unknown as Awaited>) @@ -108,7 +108,7 @@ describe('getPublicChannelIds', () => { it('fetches separately for different workspaces', async () => { const getChannels = vi.fn().mockResolvedValue([{ id: 1, public: true }]) - mockGetTwistClient.mockResolvedValue({ + mockGetCommsClient.mockResolvedValue({ channels: { getChannels }, } as unknown as Awaited>) @@ -121,27 +121,27 @@ describe('getPublicChannelIds', () => { describe('assertChannelIsPublic', () => { const originalArgv = [...process.argv] - const originalEnv = process.env.TWIST_INCLUDE_PRIVATE_CHANNELS + const originalEnv = process.env.COMMS_INCLUDE_PRIVATE_CHANNELS beforeEach(() => { clearPublicChannelCache() resetGlobalArgs() - process.argv = ['node', 'tw'] - delete process.env.TWIST_INCLUDE_PRIVATE_CHANNELS + process.argv = ['node', 'cm'] + delete process.env.COMMS_INCLUDE_PRIVATE_CHANNELS }) afterEach(() => { process.argv = originalArgv if (originalEnv !== undefined) { - process.env.TWIST_INCLUDE_PRIVATE_CHANNELS = originalEnv + process.env.COMMS_INCLUDE_PRIVATE_CHANNELS = originalEnv } else { - delete process.env.TWIST_INCLUDE_PRIVATE_CHANNELS + delete process.env.COMMS_INCLUDE_PRIVATE_CHANNELS } resetGlobalArgs() }) it('throws for private channels by default', async () => { - mockGetTwistClient.mockImplementation(() => + mockGetCommsClient.mockImplementation(() => makeMockChannels([ { id: 5, public: true }, { id: 6, public: false }, @@ -152,19 +152,19 @@ describe('assertChannelIsPublic', () => { }) it('allows public channels by default', async () => { - mockGetTwistClient.mockImplementation(() => makeMockChannels([{ id: 5, public: true }])) + mockGetCommsClient.mockImplementation(() => makeMockChannels([{ id: 5, public: true }])) await expect(assertChannelIsPublic(5, 100)).resolves.toBeUndefined() }) it('allows private channels when --include-private-channels is set', async () => { - process.argv = ['node', 'tw', '--include-private-channels'] + process.argv = ['node', 'cm', '--include-private-channels'] resetGlobalArgs() await expect(assertChannelIsPublic(999, 100)).resolves.toBeUndefined() }) it('allows private channels when env var is set', async () => { - process.env.TWIST_INCLUDE_PRIVATE_CHANNELS = '1' + process.env.COMMS_INCLUDE_PRIVATE_CHANNELS = '1' await expect(assertChannelIsPublic(999, 100)).resolves.toBeUndefined() }) }) diff --git a/src/lib/refs.test.ts b/src/lib/refs.test.ts index deec369..94bc041 100644 --- a/src/lib/refs.test.ts +++ b/src/lib/refs.test.ts @@ -258,10 +258,9 @@ describe('resolveChannelRef', () => { }) it('throws CHANNEL_NOT_FOUND when URL workspaceId conflicts with expected workspaceId', async () => { - await expect(resolveChannelRef('https://comms.todoist.com/a/2/ch/42', 1)).rejects.toHaveProperty( - 'code', - 'CHANNEL_NOT_FOUND', - ) + await expect( + resolveChannelRef('https://comms.todoist.com/a/2/ch/42', 1), + ).rejects.toHaveProperty('code', 'CHANNEL_NOT_FOUND') expect(mockGetChannel).not.toHaveBeenCalled() }) diff --git a/src/lib/refs.ts b/src/lib/refs.ts index b43bdec..cf77504 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -25,7 +25,7 @@ export function looksLikeRawId(ref: string): boolean { return /^\d+$/.test(normalized) || (/[a-zA-Z]/.test(normalized) && /\d/.test(normalized)) } -export interface ParsedTwistUrl { +export interface ParsedCommsUrl { workspaceId?: number channelId?: number threadId?: number @@ -34,7 +34,7 @@ export interface ParsedTwistUrl { messageId?: number } -export function parseCommsUrl(url: string): ParsedTwistUrl | null { +export function parseCommsUrl(url: string): ParsedCommsUrl | null { try { const parsed = new URL(url) if (!parsed.hostname.includes('comms.todoist.com')) { @@ -42,7 +42,7 @@ export function parseCommsUrl(url: string): ParsedTwistUrl | null { } const path = parsed.pathname - const result: ParsedTwistUrl = {} + const result: ParsedCommsUrl = {} // Pattern: /a/{workspaceId}/ch/{channelId}/t/{threadId}/c/{commentId} // Pattern: /a/{workspaceId}/msg/{conversationId}/m/{messageId} @@ -86,7 +86,7 @@ export function parseRef( ref: string, ): | { type: 'id'; id: number } - | { type: 'url'; parsed: ParsedTwistUrl } + | { type: 'url'; parsed: ParsedCommsUrl } | { type: 'name'; name: string } { const normalized = normalizeRef(ref) @@ -148,7 +148,7 @@ export async function resolveWorkspaceRef(ref: string): Promise { const workspace = workspaces.find((w) => w.id === parsed.id) if (!workspace) { throw new CliError('WORKSPACE_NOT_FOUND', `Workspace with ID ${parsed.id} not found`, [ - 'Run: tw workspaces to list available workspaces', + 'Run: cm workspaces to list available workspaces', ]) } return workspace @@ -160,7 +160,7 @@ export async function resolveWorkspaceRef(ref: string): Promise { throw new CliError( 'WORKSPACE_NOT_FOUND', `Workspace with ID ${parsed.parsed.workspaceId} not found`, - ['Run: tw workspaces to list available workspaces'], + ['Run: cm workspaces to list available workspaces'], ) } return workspace @@ -171,12 +171,12 @@ export async function resolveWorkspaceRef(ref: string): Promise { ambiguousCode: 'AMBIGUOUS_WORKSPACE', notFoundCode: 'WORKSPACE_NOT_FOUND', ref, - listHint: 'Run: tw workspaces to list available workspaces', + listHint: 'Run: cm workspaces to list available workspaces', }) } throw new CliError('WORKSPACE_NOT_FOUND', `Workspace "${ref}" not found`, [ - 'Run: tw workspaces to list available workspaces', + 'Run: cm workspaces to list available workspaces', ]) } @@ -193,7 +193,7 @@ export function resolveThreadId(ref: string): number { throw new CliError( 'INVALID_REF', - `Invalid thread reference: ${ref}. Use 123, id:123, or a Twist URL.`, + `Invalid thread reference: ${ref}. Use 123, id:123, or a Comms URL.`, ) } @@ -235,12 +235,12 @@ export async function resolveChannelRef(ref: string, workspaceId: number): Promi ambiguousCode: 'AMBIGUOUS_CHANNEL', notFoundCode: 'CHANNEL_NOT_FOUND', ref, - listHint: 'Run: tw channels to list available channels', + listHint: 'Run: cm channels to list available channels', }) } throw new CliError('CHANNEL_NOT_FOUND', `Channel "${ref}" not found`, [ - 'Run: tw channels to list available channels', + 'Run: cm channels to list available channels', ]) } @@ -257,7 +257,7 @@ export function resolveChannelId(ref: string): number { throw new CliError( 'INVALID_REF', - `Invalid channel reference: ${ref}. Use 123, id:123, or a Twist URL.`, + `Invalid channel reference: ${ref}. Use 123, id:123, or a Comms URL.`, ) } @@ -274,7 +274,7 @@ export function resolveCommentId(ref: string): number { throw new CliError( 'INVALID_REF', - `Invalid comment reference: ${ref}. Use 123, id:123, or a Twist URL.`, + `Invalid comment reference: ${ref}. Use 123, id:123, or a Comms URL.`, ) } @@ -291,7 +291,7 @@ export function resolveConversationId(ref: string): number { throw new CliError( 'INVALID_REF', - `Invalid conversation reference: ${ref}. Use 123, id:123, or a Twist URL.`, + `Invalid conversation reference: ${ref}. Use 123, id:123, or a Comms URL.`, ) } @@ -308,16 +308,16 @@ export function resolveMessageId(ref: string): number { throw new CliError( 'INVALID_REF', - `Invalid message reference: ${ref}. Use 123, id:123, or a Twist URL.`, + `Invalid message reference: ${ref}. Use 123, id:123, or a Comms URL.`, ) } -export type TwistUrlRoute = { +export type CommsUrlRoute = { entityType: 'message' | 'conversation' | 'comment' | 'thread' url: string } -export function classifyCommsUrl(url: string): TwistUrlRoute | null { +export function classifyCommsUrl(url: string): CommsUrlRoute | null { const parsed = parseCommsUrl(url) if (!parsed) return null @@ -378,7 +378,7 @@ export async function resolveGroupRef(ref: string, workspaceId: number): Promise } catch (error) { if (error instanceof CliError) throw error throw new CliError('GROUP_NOT_FOUND', `Group with ID ${parsed.id} not found`, [ - 'Run: tw groups to list available groups', + 'Run: cm groups to list available groups', ]) } } @@ -389,12 +389,12 @@ export async function resolveGroupRef(ref: string, workspaceId: number): Promise ambiguousCode: 'AMBIGUOUS_GROUP', notFoundCode: 'GROUP_NOT_FOUND', ref, - listHint: 'Run: tw groups to list available groups', + listHint: 'Run: cm groups to list available groups', }) } throw new CliError('GROUP_NOT_FOUND', `Group "${ref}" not found`, [ - 'Run: tw groups to list available groups', + 'Run: cm groups to list available groups', ]) } @@ -419,7 +419,7 @@ export async function resolveUserRefs(refs: string, workspaceId: number): Promis if (matches.length === 0) { throw new CliError('USER_NOT_FOUND', `No user found matching "${ref}"`, [ - 'Run: tw users to list workspace members', + 'Run: cm users to list workspace members', ]) } diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 4622199..7dc6e89 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -3,7 +3,7 @@ import packageJson from '../../../package.json' with { type: 'json' } export const SKILL_NAME = 'comms-cli' export const SKILL_DESCRIPTION = - 'Twist messaging CLI. View and respond to inbox threads, channel threads, direct messages, mentions, and group conversations; search, react, archive, mute, and manage workspaces. Use when the user mentions Twist, asks about their inbox, mentions, threads, DMs, channels, or wants to read or send Twist messages.' + 'Comms messaging CLI. View and respond to inbox threads, channel threads, direct messages, mentions, and group conversations; search, react, archive, mute, and manage workspaces. Use when the user mentions Comms, asks about their inbox, mentions, threads, DMs, channels, or wants to read or send Comms messages.' export const SKILL_AUTHOR = 'Doist' @@ -11,116 +11,116 @@ export const SKILL_LICENSE = 'MIT' export const SKILL_VERSION = packageJson.version -export const SKILL_CONTENT = `# Comms CLI (tw) +export const SKILL_CONTENT = `# Comms CLI (cm) -Access Twist messaging via the \`tw\` CLI. Use when the user asks about their Twist workspaces, threads, messages, or wants to interact with Twist in any way. +Access Comms messaging via the \`cm\` CLI. Use when the user asks about their Comms workspaces, threads, messages, or wants to interact with Comms in any way. ## Setup \`\`\`bash -tw auth login # OAuth login (opens browser, read-write) -tw auth login --read-only # OAuth login with read-only scope -tw auth login --callback-port # Override the local OAuth callback port (default 8766) -tw auth login --json # Emit a JSON envelope for scripted / agent use -tw auth login --ndjson # Emit an NDJSON envelope for scripted / agent use -tw auth token # Save API token manually (prompts securely; scope unknown, assumed write-capable) -tw auth status # Verify authentication + show mode -tw auth status --json # Full status payload as JSON (--ndjson also supported) -tw auth status --user # Target a specific stored account (id, id:, or display name) -tw --user auth # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it -tw auth logout # Remove saved token and auth metadata -tw auth logout --json # Emits \`{"ok": true}\` (--ndjson is silent) -tw auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND -tw auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set) -tw auth token view --user # Print the saved token for a specific stored account -tw account [list|current|use |remove ] # Manage stored accounts; all support --json/--ndjson - # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"legacy"} -tw auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set -tw workspaces # List available workspaces -tw workspace use # Set current workspace -tw completion install # Install shell completions -tw config view # Show the current CLI configuration file (token masked) -tw config set # Set a user preference (e.g. unarchive-new-threads true) -tw doctor # Diagnose CLI setup and environment issues -tw update # Update CLI to latest version -tw changelog # Show recent changelog entries +cm auth login # OAuth login (opens browser, read-write) +cm auth login --read-only # OAuth login with read-only scope +cm auth login --callback-port # Override the local OAuth callback port (default 8766) +cm auth login --json # Emit a JSON envelope for scripted / agent use +cm auth login --ndjson # Emit an NDJSON envelope for scripted / agent use +cm auth token # Save API token manually (prompts securely; scope unknown, assumed write-capable) +cm auth status # Verify authentication + show mode +cm auth status --json # Full status payload as JSON (--ndjson also supported) +cm auth status --user # Target a specific stored account (id, id:, or display name) +cm --user auth # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it +cm auth logout # Remove saved token and auth metadata +cm auth logout --json # Emits \`{"ok": true}\` (--ndjson is silent) +cm auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND +cm auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set) +cm auth token view --user # Print the saved token for a specific stored account +cm account [list|current|use |remove ] # Manage stored accounts; all support --json/--ndjson + # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} +cm auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set +cm workspaces # List available workspaces +cm workspace use # Set current workspace +cm completion install # Install shell completions +cm config view # Show the current CLI configuration file (token masked) +cm config set # Set a user preference (e.g. unarchive-new-threads true) +cm doctor # Diagnose CLI setup and environment issues +cm update # Update CLI to latest version +cm changelog # Show recent changelog entries \`\`\` -Stored auth uses the system credential manager when available. If secure storage is unavailable, \`tw\` warns and falls back to \`~/.config/comms-cli/config.json\`. \`COMMS_API_TOKEN\` always takes priority over the stored token, and legacy plaintext config tokens are migrated automatically when secure storage is available. +Stored auth uses the system credential manager when available. If secure storage is unavailable, \`cm\` warns and falls back to \`~/.config/comms-cli/config.json\`. \`COMMS_API_TOKEN\` always takes priority over the stored token. -In read-only mode (\`tw auth login --read-only\`), commands that modify Twist data (reply, archive, react, delete, etc.) are blocked by the CLI. Externally provided tokens (\`COMMS_API_TOKEN\` or \`tw auth token\`) are treated as unknown scope and assumed write-capable. +In read-only mode (\`cm 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 \`cm auth token\`) are treated as unknown scope and assumed write-capable. ## View by URL \`\`\`bash -tw view # View any Comms entity by URL +cm view # View any Comms entity by URL \`\`\` Routes automatically based on URL structure: -- Message URL → \`tw msg view\` -- Conversation URL → \`tw conversation view\` -- Thread+comment URL → \`tw thread view\` (comment ID extracted from URL) -- Thread URL → \`tw thread view\` +- Message URL → \`cm msg view\` +- Conversation URL → \`cm conversation view\` +- Thread+comment URL → \`cm thread view\` (comment ID extracted from URL) +- Thread URL → \`cm thread view\` All target command flags pass through (e.g. \`--json\`, \`--raw\`, \`--full\`). ## Inbox \`\`\`bash -tw inbox # Show inbox threads -tw inbox --unread # Only unread threads -tw inbox --archive-filter all # Show active + done threads -tw inbox --archive-filter archived # Show only done threads -tw inbox --channel # Filter by channel name (fuzzy) -tw inbox --since # Filter by date (ISO format) -tw inbox --limit # Max items (default: 50) +cm inbox # Show inbox threads +cm inbox --unread # Only unread threads +cm inbox --archive-filter all # Show active + done threads +cm inbox --archive-filter archived # Show only done threads +cm inbox --channel # Filter by channel name (fuzzy) +cm inbox --since # Filter by date (ISO format) +cm inbox --limit # Max items (default: 50) \`\`\` ## Threads \`\`\`bash -tw thread # View thread (shorthand for view) -tw thread view # View thread with comments -tw thread view --comment # View a specific comment -tw thread view # Comment ID extracted from URL -tw thread view --unread # Show only unread comments -tw thread view --context 3 # Include 3 read comments before unread -tw thread view --limit 20 # Limit number of comments -tw thread view --since # Comments newer than date -tw thread view --raw # Show raw markdown -tw thread create "Title" "content" # Create a new thread -tw thread create "Title" "content" --json # Create and return as JSON -tw thread create "Title" "content" --json --full # Include all thread fields -tw thread create "Title" "content" --notify 123,456 # Notify specific users -tw thread create "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Twist auto-archive) -tw thread create "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true -tw thread create "Title" "content" --dry-run # Preview without posting -tw thread reply "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) -tw thread reply "content" --notify EVERYONE # Notify all workspace members -tw thread reply "content" --notify 123,id:456 # Notify specific user IDs -tw thread reply "content" --json # Post and return comment as JSON -tw thread reply "content" --json --full # Include all comment fields -tw thread reply "content" --close # Reply and close the thread -tw thread reply "content" --reopen # Reply and reopen a closed thread -tw thread done # Archive thread (mark done) -tw thread done --json # Archive and return status as JSON -tw thread mute # Mute thread for 60 minutes (default) -tw thread mute --minutes 480 # Mute for custom duration -tw thread mute --json # Mute and return { id, mutedUntil } as JSON -tw thread mute --json --full # Mute and return full thread as JSON -tw thread unmute # Unmute a muted thread -tw thread unmute --json # Unmute and return { id, mutedUntil } as JSON -tw thread delete # Preview thread deletion (requires --yes to execute) -tw thread delete --yes # Permanently delete a thread -tw thread delete --yes --json # Delete and return status as JSON -tw thread rename "New title" # Rename a thread (change its title) -tw thread rename "New title" --json # Rename and return { id, title } as JSON -tw thread rename "New title" --json --full # Rename and return full thread as JSON -tw thread update "New body" # Update a thread's body (the first post) -echo "New body" | tw thread update # Update body from stdin -tw thread update "New body" --dry-run # Preview without updating -tw thread update "New body" --json # Update and return { id, content } as JSON -tw thread update "New body" --json --full # Update and return full thread as JSON +cm thread # View thread (shorthand for view) +cm thread view # View thread with comments +cm thread view --comment # View a specific comment +cm thread view # Comment ID extracted from URL +cm thread view --unread # Show only unread comments +cm thread view --context 3 # Include 3 read comments before unread +cm thread view --limit 20 # Limit number of comments +cm thread view --since # Comments newer than date +cm thread view --raw # Show raw markdown +cm thread create "Title" "content" # Create a new thread +cm thread create "Title" "content" --json # Create and return as JSON +cm thread create "Title" "content" --json --full # Include all thread fields +cm thread create "Title" "content" --notify 123,456 # Notify specific users +cm thread create "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive) +cm thread create "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true +cm thread create "Title" "content" --dry-run # Preview without posting +cm thread reply "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) +cm thread reply "content" --notify EVERYONE # Notify all workspace members +cm thread reply "content" --notify 123,id:456 # Notify specific user IDs +cm thread reply "content" --json # Post and return comment as JSON +cm thread reply "content" --json --full # Include all comment fields +cm thread reply "content" --close # Reply and close the thread +cm thread reply "content" --reopen # Reply and reopen a closed thread +cm thread done # Archive thread (mark done) +cm thread done --json # Archive and return status as JSON +cm thread mute # Mute thread for 60 minutes (default) +cm thread mute --minutes 480 # Mute for custom duration +cm thread mute --json # Mute and return { id, mutedUntil } as JSON +cm thread mute --json --full # Mute and return full thread as JSON +cm thread unmute # Unmute a muted thread +cm thread unmute --json # Unmute and return { id, mutedUntil } as JSON +cm thread delete # Preview thread deletion (requires --yes to execute) +cm thread delete --yes # Permanently delete a thread +cm thread delete --yes --json # Delete and return status as JSON +cm thread rename "New title" # Rename a thread (change its title) +cm thread rename "New title" --json # Rename and return { id, title } as JSON +cm thread rename "New title" --json --full # Rename and return full thread as JSON +cm thread update "New body" # Update a thread's body (the first post) +echo "New body" | cm thread update # Update body from stdin +cm thread update "New body" --dry-run # Preview without updating +cm thread update "New body" --json # Update and return { id, content } as JSON +cm thread update "New body" --json --full # Update and return full thread as JSON \`\`\` Default \`--notify\` for reply is EVERYONE_IN_THREAD, which may notify more people than intended. Before posting, confirm with the user whether specific people should be notified instead (via \`--notify \`). Options: EVERYONE, EVERYONE_IN_THREAD, or comma-separated ID refs. @@ -130,141 +130,141 @@ Default \`--notify\` for reply is EVERYONE_IN_THREAD, which may notify more peop ## Thread Comments \`\`\`bash -tw comment # View a comment (shorthand for view) -tw comment view # View a single thread comment -tw comment view --raw # Show raw markdown -tw comment view --json # Output as JSON -tw comment view --ndjson # Output as newline-delimited JSON -tw comment view --json --full # Include all fields in JSON output -tw comment update "new content" # Update a thread comment -tw comment update "content" --json # Update and return updated comment as JSON -tw comment update "content" --json --full # Include all comment fields -tw comment delete # Delete a thread comment -tw comment delete --json # Delete and return status as JSON +cm comment # View a comment (shorthand for view) +cm comment view # View a single thread comment +cm comment view --raw # Show raw markdown +cm comment view --json # Output as JSON +cm comment view --ndjson # Output as newline-delimited JSON +cm comment view --json --full # Include all fields in JSON output +cm comment update "new content" # Update a thread comment +cm comment update "content" --json # Update and return updated comment as JSON +cm comment update "content" --json --full # Include all comment fields +cm comment delete # Delete a thread comment +cm comment delete --json # Delete and return status as JSON \`\`\` ## Conversations (DMs/Groups) \`\`\`bash -tw conversation unread # List unread conversations -tw conversation # View conversation (shorthand for view) -tw conversation view # View conversation messages -tw conversation with # Find your 1:1 DM with a user -tw conversation with --snippet # Include the latest message preview -tw conversation with --include-groups # List any conversations with that user -tw conversation reply "content" # Send a message -tw conversation reply "content" --json # Send and return message as JSON -tw conversation reply "content" --json --full # Include all message fields -tw conversation done # Archive conversation -tw conversation done --json # Archive and return status as JSON -tw conversation mute # Mute conversation for 60 minutes (default) -tw conversation mute --minutes 480 # Mute for custom duration -tw conversation mute --json # Mute and return { id, mutedUntil } as JSON -tw conversation mute --json --full # Mute and return full conversation as JSON -tw conversation unmute # Unmute a muted conversation -tw conversation unmute --json # Unmute and return { id, mutedUntil } as JSON +cm conversation unread # List unread conversations +cm conversation # View conversation (shorthand for view) +cm conversation view # View conversation messages +cm conversation with # Find your 1:1 DM with a user +cm conversation with --snippet # Include the latest message preview +cm conversation with --include-groups # List any conversations with that user +cm conversation reply "content" # Send a message +cm conversation reply "content" --json # Send and return message as JSON +cm conversation reply "content" --json --full # Include all message fields +cm conversation done # Archive conversation +cm conversation done --json # Archive and return status as JSON +cm conversation mute # Mute conversation for 60 minutes (default) +cm conversation mute --minutes 480 # Mute for custom duration +cm conversation mute --json # Mute and return { id, mutedUntil } as JSON +cm conversation mute --json --full # Mute and return full conversation as JSON +cm conversation unmute # Unmute a muted conversation +cm conversation unmute --json # Unmute and return { id, mutedUntil } as JSON \`\`\` -Alias: \`tw convo\` works the same as \`tw conversation\`. +Alias: \`cm convo\` works the same as \`cm conversation\`. ## Conversation Messages \`\`\`bash -tw msg # View a message (shorthand for view) -tw msg view # View a single conversation message -tw msg update "content" # Edit a conversation message -tw msg update "content" --json # Edit and return updated message as JSON -tw msg update "content" --json --full # Include all message fields -tw msg delete # Delete a conversation message -tw msg delete --json # Delete and return status as JSON +cm msg # View a message (shorthand for view) +cm msg view # View a single conversation message +cm msg update "content" # Edit a conversation message +cm msg update "content" --json # Edit and return updated message as JSON +cm msg update "content" --json --full # Include all message fields +cm msg delete # Delete a conversation message +cm msg delete --json # Delete and return status as JSON \`\`\` -Alias: \`tw message\` works the same as \`tw msg\`. +Alias: \`cm message\` works the same as \`cm msg\`. ## Search \`\`\`bash -tw mentions # Show content mentioning current user -tw mentions --since 2026-04-01 --all # Fetch every mention since a date -tw mentions --type threads --json # Limit mentions to threads -tw search "query" # Search content -tw search "query" --type threads # Filter: threads, messages, or all -tw search "query" --author # Filter by author -tw search "query" --to # Messages sent to user -tw search "query" --title-only # Search thread titles only -tw search "query" --mention-me # Results mentioning current user -tw search "query" --conversation # Limit to conversations (comma-separated refs) -tw search "query" --since # Content from date -tw search "query" --until # Content until date -tw search "query" --channel # Filter by channel refs (comma-separated) -tw search "query" --limit # Max results (default: 50) -tw search "query" --cursor # Pagination cursor -tw search "query" --all # Fetch all result pages +cm mentions # Show content mentioning current user +cm mentions --since 2026-04-01 --all # Fetch every mention since a date +cm mentions --type threads --json # Limit mentions to threads +cm search "query" # Search content +cm search "query" --type threads # Filter: threads, messages, or all +cm search "query" --author # Filter by author +cm search "query" --to # Messages sent to user +cm search "query" --title-only # Search thread titles only +cm search "query" --mention-me # Results mentioning current user +cm search "query" --conversation # Limit to conversations (comma-separated refs) +cm search "query" --since # Content from date +cm search "query" --until # Content until date +cm search "query" --channel # Filter by channel refs (comma-separated) +cm search "query" --limit # Max results (default: 50) +cm search "query" --cursor # Pagination cursor +cm search "query" --all # Fetch all result pages \`\`\` ## Users, Channels & Groups \`\`\`bash -tw user # Show current user info -tw user --json # JSON output -tw user --json --full # Include all fields in JSON output -tw users # List workspace users -tw users --search # Filter by name/email -tw channels # List active joined workspace channels (alias of: tw channel list) -tw channels --state all # Include archived joined channels too -tw channels --scope discoverable # Active public channels you can see but have not joined -tw channels --scope public --state all --json # All visible public channels, with joined status -tw channel threads # List threads in a channel (fuzzy name, id:, numeric ID, or URL) -tw channel threads "general" --unread # Only unread threads -tw channel threads --archive-filter all # Include archived threads (active|archived|all) -tw channel threads --since 2026-01-01 # Filter by last-updated date (ISO) -tw channel threads --limit 20 # Max threads per page (default: 50) -tw channel threads --limit 20 --cursor # Paginate -tw channel threads --json # { results, nextCursor } with isUnread + url -tw groups # List workspace groups -tw groups --search "frontend" # Filter groups by name (case-insensitive) -tw groups --json # JSON output -tw groups --json --full # Include all fields in JSON output -tw groups view # Show group with member details -tw groups view --json # JSON output with id, name, workspaceId, members -tw groups view --json --full # Include all fields in JSON output -tw groups create "Name" # Create a new group -tw groups create "Name" --users alice@doist.com,bob@doist.com # Create with members -tw groups create "Name" --json # Output created group as JSON -tw groups rename "New name" # Rename a group -tw groups rename "Name" --json # Output renamed group as JSON -tw groups delete --yes # Delete a group (requires --yes) -tw groups delete --dry-run # Preview deletion -tw groups add-user user1 user2 # Add users to a group -tw groups add-user a@d.com,b@d.com # Comma-separated refs -tw groups add-user id:123 --json # Output result as JSON -tw groups remove-user user1 user2 # Remove users from a group -tw groups remove-user id:123,id:456 # Comma-separated ID refs +cm user # Show current user info +cm user --json # JSON output +cm user --json --full # Include all fields in JSON output +cm users # List workspace users +cm users --search # Filter by name/email +cm channels # List active joined workspace channels (alias of: cm channel list) +cm channels --state all # Include archived joined channels too +cm channels --scope discoverable # Active public channels you can see but have not joined +cm channels --scope public --state all --json # All visible public channels, with joined status +cm channel threads # List threads in a channel (fuzzy name, id:, numeric ID, or URL) +cm channel threads "general" --unread # Only unread threads +cm channel threads --archive-filter all # Include archived threads (active|archived|all) +cm channel threads --since 2026-01-01 # Filter by last-updated date (ISO) +cm channel threads --limit 20 # Max threads per page (default: 50) +cm channel threads --limit 20 --cursor # Paginate +cm channel threads --json # { results, nextCursor } with isUnread + url +cm groups # List workspace groups +cm groups --search "frontend" # Filter groups by name (case-insensitive) +cm groups --json # JSON output +cm groups --json --full # Include all fields in JSON output +cm groups view # Show group with member details +cm groups view --json # JSON output with id, name, workspaceId, members +cm groups view --json --full # Include all fields in JSON output +cm groups create "Name" # Create a new group +cm groups create "Name" --users alice@doist.com,bob@doist.com # Create with members +cm groups create "Name" --json # Output created group as JSON +cm groups rename "New name" # Rename a group +cm groups rename "Name" --json # Output renamed group as JSON +cm groups delete --yes # Delete a group (requires --yes) +cm groups delete --dry-run # Preview deletion +cm groups add-user user1 user2 # Add users to a group +cm groups add-user a@d.com,b@d.com # Comma-separated refs +cm groups add-user id:123 --json # Output result as JSON +cm groups remove-user user1 user2 # Remove users from a group +cm groups remove-user id:123,id:456 # Comma-separated ID refs \`\`\` -If a channel is not found in \`tw channels\`, widen with broader listings such as \`tw channels --scope public\`, then \`tw channels --scope public --state all\`. Check \`tw channels --help\` for other available filters. +If a channel is not found in \`cm channels\`, widen with broader listings such as \`cm channels --scope public\`, then \`cm channels --scope public --state all\`. Check \`cm channels --help\` for other available filters. -\`tw channel threads\` returns every thread in the channel; pagination filters (\`--limit\`, \`--cursor\`, \`--since\`, \`--until\`, \`--unread\`) are applied client-side after fetch. \`--archive-filter\` is applied server-side. Results are sorted newest-first by last activity. In \`--json\` / \`--ndjson\`, the response includes a \`nextCursor\` string (opaque) you can pass via \`--cursor\` to fetch the next page; NDJSON emits the cursor as a final \`{ "_meta": true, "nextCursor": "..." }\` line. +\`cm channel threads\` returns every thread in the channel; pagination filters (\`--limit\`, \`--cursor\`, \`--since\`, \`--until\`, \`--unread\`) are applied client-side after fetch. \`--archive-filter\` is applied server-side. Results are sorted newest-first by last activity. In \`--json\` / \`--ndjson\`, the response includes a \`nextCursor\` string (opaque) you can pass via \`--cursor\` to fetch the next page; NDJSON emits the cursor as a final \`{ "_meta": true, "nextCursor": "..." }\` line. ## Away Status \`\`\`bash -tw away # Show current away status -tw away set [until] # Set away (type: vacation, parental, sickleave, other) -tw away set vacation 2026-03-20 # Away until March 20 -tw away set vacation 2026-03-20 --from 2026-03-15 # Custom start date -tw away clear # Clear away status +cm away # Show current away status +cm away set [until] # Set away (type: vacation, parental, sickleave, other) +cm away set vacation 2026-03-20 # Away until March 20 +cm away set vacation 2026-03-20 --from 2026-03-15 # Custom start date +cm away clear # Clear away status \`\`\` ## Reactions \`\`\`bash -tw react thread 👍 # Add reaction to thread -tw react comment +1 # Add reaction (shortcode) -tw react message heart # Add reaction to DM message -tw react thread 👍 --json # Output result as JSON -tw unreact thread 👍 # Remove reaction -tw unreact thread 👍 --json # Output result as JSON +cm react thread 👍 # Add reaction to thread +cm react comment +1 # Add reaction (shortcode) +cm react message heart # Add reaction to DM message +cm react thread 👍 --json # Output result as JSON +cm unreact thread 👍 # Remove reaction +cm unreact thread 👍 --json # Output result as JSON \`\`\` Supported shortcodes: +1, -1, heart, tada, smile, laughing, thinking, fire, check, x, eyes, pray, clap, rocket, wave @@ -272,52 +272,52 @@ Supported shortcodes: +1, -1, heart, tada, smile, laughing, thinking, fire, chec ## Shell Completions \`\`\`bash -tw completion install # Install tab completions (prompts for shell) -tw completion install bash # Install for specific shell -tw completion install zsh -tw completion install fish -tw completion uninstall # Remove completions +cm completion install # Install tab completions (prompts for shell) +cm completion install bash # Install for specific shell +cm completion install zsh +cm completion install fish +cm completion uninstall # Remove completions \`\`\` ### Diagnostics \`\`\`bash -tw doctor # Run local + network diagnostics -tw doctor --offline # Skip Twist and npm network checks -tw doctor --json # JSON output with per-check results +cm doctor # Run local + network diagnostics +cm doctor --offline # Skip Comms and npm network checks +cm doctor --json # JSON output with per-check results \`\`\` ### Configuration \`\`\`bash -tw config view # Pretty-printed config, token masked, labels actual token source -tw config view --json # Raw JSON, token masked -tw config view --show-token # Include the full token -tw config set unarchive-new-threads true # Persist: always unarchive new threads so they land in your Inbox -tw config set unarchive-new-threads false # Persist: keep Twist's default (thread auto-archived for author) +cm config view # Pretty-printed config, token masked, labels actual token source +cm config view --json # Raw JSON, token masked +cm config view --show-token # Include the full token +cm config set unarchive-new-threads true # Persist: always unarchive new threads so they land in your Inbox +cm config set unarchive-new-threads false # Persist: keep Comms's default (thread auto-archived for author) \`\`\` -User preferences are stored under \`userSettings\` in the config file. Currently supported keys: \`unarchive-new-threads\`. The flag on \`tw thread create\` (\`--unarchive\` / \`--no-unarchive\`) overrides this default per-invocation. +User preferences are stored under \`userSettings\` in the config file. Currently supported keys: \`unarchive-new-threads\`. The flag on \`cm thread create\` (\`--unarchive\` / \`--no-unarchive\`) overrides this default per-invocation. ### Update \`\`\`bash -tw update # Update CLI to latest version -tw update --check # Check for updates without installing, show channel -tw update --check --json # Same, JSON envelope -tw update --check --ndjson # Same, newline-delimited JSON envelope -tw update --channel # Show current update channel -tw update switch --stable # Switch to stable release channel -tw update switch --pre-release # Switch to pre-release (next) channel -tw update switch --pre-release --json # Same, JSON envelope -tw update switch --pre-release --ndjson # Same, newline-delimited JSON envelope +cm update # Update CLI to latest version +cm update --check # Check for updates without installing, show channel +cm update --check --json # Same, JSON envelope +cm update --check --ndjson # Same, newline-delimited JSON envelope +cm update --channel # Show current update channel +cm update switch --stable # Switch to stable release channel +cm update switch --pre-release # Switch to pre-release (next) channel +cm update switch --pre-release --json # Same, JSON envelope +cm update switch --pre-release --ndjson # Same, newline-delimited JSON envelope \`\`\` ### Changelog \`\`\`bash -tw changelog # Show last 5 versions -tw changelog -n 3 # Show last 3 versions -tw changelog --count 10 # Show last 10 versions +cm changelog # Show last 5 versions +cm changelog -n 3 # Show last 3 versions +cm changelog --count 10 # Show last 10 versions \`\`\` ## Global Options @@ -327,7 +327,7 @@ tw changelog --count 10 # Show last 10 versions --progress-jsonl # Machine-readable progress events (JSONL to stderr) --progress-jsonl= # Same, but write events to instead of stderr --progress-jsonl # Same as above (space-separated form also accepted) ---accessible # Add text labels to color-coded output (also: TW_ACCESSIBLE=1) +--accessible # Add text labels to color-coded output (also: CM_ACCESSIBLE=1) --non-interactive # Disable interactive prompts (auto-detected when stdin is not a TTY) --interactive # Force interactive mode even when stdin is not a TTY \`\`\` @@ -357,7 +357,7 @@ Run without --dry-run to execute. Commands accept flexible references: - **Numeric IDs**: \`123\` or \`id:123\` -- **Twist URLs**: Full \`https://comms.todoist.com/...\` URLs (parsed automatically) +- **Comms URLs**: Full \`https://comms.todoist.com/...\` URLs (parsed automatically) - **Fuzzy names**: For workspaces/users - \`"My Workspace"\` or partial matches ## Piping Content @@ -365,9 +365,9 @@ Commands accept flexible references: Commands that accept content (\`thread create\`, \`thread reply\`, \`comment update\`, \`conversation reply\`, \`msg update\`) auto-detect piped stdin: \`\`\`bash -cat notes.md | tw thread reply -tw thread create "Title" < body.md -echo "Quick reply" | tw conversation reply +cat notes.md | cm thread reply +cm thread create "Title" < body.md +echo "Quick reply" | cm conversation reply \`\`\` If no content argument is provided and no stdin is piped, the CLI opens \`$EDITOR\` for interactive input. In non-TTY environments (e.g. when called by an agent or in a pipeline), the editor is automatically skipped and the command fails fast with an actionable error message. Use \`--non-interactive\` to force this behavior even in a TTY, or \`--interactive\` to override auto-detection. @@ -376,33 +376,33 @@ If no content argument is provided and no stdin is piped, the CLI opens \`$EDITO **View by URL (auto-routes to the right command):** \`\`\`bash -tw view https://comms.todoist.com/a/1585/ch/100/t/200 # View thread -tw view https://comms.todoist.com/a/1585/ch/100/t/200/c/300 # View comment -tw view https://comms.todoist.com/a/1585/msg/400 # View conversation -tw view https://comms.todoist.com/a/1585/msg/400/m/500 --json # View message as JSON +cm view https://comms.todoist.com/a/1585/ch/100/t/200 # View thread +cm view https://comms.todoist.com/a/1585/ch/100/t/200/c/300 # View comment +cm view https://comms.todoist.com/a/1585/msg/400 # View conversation +cm view https://comms.todoist.com/a/1585/msg/400/m/500 --json # View message as JSON \`\`\` **Check inbox and respond:** \`\`\`bash -tw inbox --unread --json -tw thread view --unread -tw thread reply "Thanks, I'll look into this." -tw thread done +cm inbox --unread --json +cm thread view --unread +cm thread reply "Thanks, I'll look into this." +cm thread done \`\`\` **Search and review:** \`\`\`bash -tw mentions --since 2026-04-01 --all --json -tw search "deployment" --type threads --json -tw thread view +cm mentions --since 2026-04-01 --all --json +cm search "deployment" --type threads --json +cm thread view \`\`\` **Check DMs:** \`\`\`bash -tw conversation unread --json -tw conversation view -tw conversation with "Alice Example" -tw conversation reply "Got it, thanks!" +cm conversation unread --json +cm conversation view +cm conversation with "Alice Example" +cm conversation reply "Got it, thanks!" \`\`\` ` diff --git a/src/lib/skills/create-installer.ts b/src/lib/skills/create-installer.ts index 003ad84..7b35e14 100644 --- a/src/lib/skills/create-installer.ts +++ b/src/lib/skills/create-installer.ts @@ -65,7 +65,7 @@ export function createInstaller(config: InstallerConfig): SkillInstaller { if (!exists) { throw new CliError('NOT_INSTALLED', `Skill not installed at ${skillPath}`, [ - `Run: tw skill install ${config.name}`, + `Run: cm skill install ${config.name}`, ]) } @@ -78,7 +78,7 @@ export function createInstaller(config: InstallerConfig): SkillInstaller { if (!exists) { throw new CliError('NOT_INSTALLED', `Skill not installed at ${skillPath}`, [ - `Run: tw skill install ${config.name}`, + `Run: cm skill install ${config.name}`, ]) } diff --git a/src/lib/update.ts b/src/lib/update.ts index e70d414..1b7dfc1 100644 --- a/src/lib/update.ts +++ b/src/lib/update.ts @@ -9,10 +9,10 @@ export async function fetchLatestVersion(channel: UpdateChannel): Promise ({ updateAllInstalledSkills: vi.fn().mockResolvedValue({ updated: [], skipped: [], errors: [] }), })) -vi.mock('./lib/migrate-auth.js', () => ({ - runMigrateLegacyAuth: vi.fn().mockResolvedValue({ status: 'no-legacy-state' }), -})) - describe('postinstall', () => { beforeEach(() => { vi.resetModules() @@ -24,16 +20,4 @@ describe('postinstall', () => { vi.mocked(updateAllInstalledSkills).mockRejectedValueOnce(new Error('fail')) await expect(import('./postinstall.js')).resolves.not.toThrow() }) - - it('invokes runMigrateLegacyAuth({ silent: true }) on import', async () => { - const { runMigrateLegacyAuth } = await import('./lib/migrate-auth.js') - await import('./postinstall.js') - expect(runMigrateLegacyAuth).toHaveBeenCalledWith({ silent: true }) - }) - - it('swallows errors from runMigrateLegacyAuth so it never blocks npm install', async () => { - const { runMigrateLegacyAuth } = await import('./lib/migrate-auth.js') - vi.mocked(runMigrateLegacyAuth).mockRejectedValueOnce(new Error('migration boom')) - await expect(import('./postinstall.js')).resolves.not.toThrow() - }) }) diff --git a/src/postinstall.ts b/src/postinstall.ts index 5b9ab72..1043514 100644 --- a/src/postinstall.ts +++ b/src/postinstall.ts @@ -1,7 +1,4 @@ -import { runMigrateLegacyAuth } from './lib/migrate-auth.js' import { updateAllInstalledSkills } from './lib/skills/update-installed.js' -// Failures must not break `npm install`. `createCommsTokenStore` re-runs the -// migration lazily for users who installed with `--ignore-scripts`. +// Failures must not break `npm install`. updateAllInstalledSkills({ local: false }).catch(() => {}) -runMigrateLegacyAuth({ silent: true }).catch(() => {}) From d0e2087dbe83277697047e4e2a19cbc3a29a19fc Mon Sep 17 00:00:00 2001 From: Amir Date: Wed, 20 May 2026 17:11:15 +0200 Subject: [PATCH 2/5] fix: address Doistbot review feedback - account/current: restore empty-id guard so `cm auth token` snapshots render as `source: "token-only"` instead of printing blank identity fields. `loginWithToken` still persists `{ id: '', label: '' }`. - account.test: add regression coverage for the empty-id snapshot in both text and --json paths (replaces the deleted legacy-source tests). - SKILL_CONTENT: document the new `source:"token-only"` payload variant. - docs/SPEC: drop the dead "legacy plaintext token during auto-migration" step from token resolution priority. - README: switch the manual-token example to the prompt-based flow (`cm auth token` with no arg) so the secret isn't visible in `ps` or shell history (matches the Doist secrets-management standard). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 +++- docs/SPEC.md | 3 +-- src/commands/account/account.test.ts | 31 ++++++++++++++++++++++++++++ src/commands/account/current.ts | 11 ++++++++++ src/lib/skills/content.ts | 2 +- 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 869c30e..d5e0d61 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,11 @@ If secure storage is unavailable, the CLI warns and falls back to `~/.config/com **Manual token:** ```bash -cm auth token "your-token" +cm auth token ``` +The CLI prompts for the token without echoing it. Do **not** pass the token as a positional argument — it would be visible in `ps` / shell history. + **Environment variable:** ```bash diff --git a/docs/SPEC.md b/docs/SPEC.md index 15db1bd..ed8dcda 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -51,8 +51,7 @@ Token resolution (priority order): 1. Environment variable: `COMMS_API_TOKEN` 2. System credential manager (Keychain, Credential Manager, or Secret Service) -3. Legacy plaintext token in `~/.config/comms-cli/config.json` during auto-migration -4. Plaintext config fallback when the OS credential store is unavailable +3. Plaintext config fallback when the OS credential store is unavailable ## Workspace Scoping diff --git a/src/commands/account/account.test.ts b/src/commands/account/account.test.ts index 411dcfc..51f5d26 100644 --- a/src/commands/account/account.test.ts +++ b/src/commands/account/account.test.ts @@ -158,6 +158,37 @@ describe('account command', () => { source: 'config', }) }) + + // `cm auth token` persists `{ id: '', label: '' }` since manual + // token entry has no identity. `account current` must render that + // shape as a distinct "token-only" source, not as a regular account + // with blank fields. + const EMPTY_ID_SNAPSHOT = { + token: 'tk_manual', + account: { id: '', label: '', authMode: 'unknown' as const, authScope: '' }, + } + + it('renders a token-only notice when active() returns an empty-id snapshot', async () => { + vi.stubEnv(TOKEN_ENV_VAR, '') + storeMocks.active.mockResolvedValue(EMPTY_ID_SNAPSHOT) + + await createProgram().parseAsync(['node', 'cm', 'account', 'current']) + + const output = stdout() + expect(output).toContain('saved via `cm auth token`') + expect(output).not.toMatch(/Active account: id: {2}/) + }) + + it('emits {source:"token-only"} in --json mode for empty-id snapshots', async () => { + vi.stubEnv(TOKEN_ENV_VAR, '') + storeMocks.active.mockResolvedValue(EMPTY_ID_SNAPSHOT) + + await createProgram().parseAsync(['node', 'cm', 'account', 'current', '--json']) + + expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ + source: 'token-only', + }) + }) }) describe('use', () => { diff --git a/src/commands/account/current.ts b/src/commands/account/current.ts index 180d94d..90b2e14 100644 --- a/src/commands/account/current.ts +++ b/src/commands/account/current.ts @@ -21,6 +21,17 @@ export async function currentAccount(options: ViewOptions, store: CommsTokenStor } const { account } = snapshot + // `cm auth token` persists `{ id: '', label: '' }` because manual token + // entry has no identity. Render that case explicitly rather than printing + // blank fields. + if (!account.id || !account.label) { + emitView(options, { source: 'token-only' }, () => [ + 'Active token saved via `cm auth token` (no associated identity).', + chalk.dim('Run `cm auth login` to attach an account to the token.'), + ]) + return + } + emitView( options, { diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 7dc6e89..3a73ce0 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -34,7 +34,7 @@ cm auth logout --user # Target a specific stored account; mismatched cm auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set) cm auth token view --user # Print the saved token for a specific stored account cm account [list|current|use |remove ] # Manage stored accounts; all support --json/--ndjson - # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} + # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"token-only"} cm auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set cm workspaces # List available workspaces cm workspace use # Set current workspace From b16f5de3d41c9d4b9ae4eac4d88c2ace04a651ca Mon Sep 17 00:00:00 2001 From: Amir Date: Wed, 20 May 2026 17:22:52 +0200 Subject: [PATCH 3/5] fix: reject token as positional argument in `cm auth token` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doistbot flagged that the previous commit only updated the README — the command itself still accepted `cm auth token `, which violates the Doist Secrets Management Standard (process lists + shell history leak the secret). - `cm auth token` no longer takes a positional argument; the only path is the hidden-input prompt or `COMMS_API_TOKEN` for non-interactive use. - `loginWithToken()` is now zero-arg. - Updated auth.test for the prompt-only flow. - skills/comms-cli/SKILL.md: refresh the stale `source:"legacy"` comment to `source:"token-only"` to match `src/lib/skills/content.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/comms-cli/SKILL.md | 2 +- src/commands/auth/auth.test.ts | 66 +++++++++++++++++++++------------- src/commands/auth/index.ts | 13 ++++--- src/commands/auth/token.ts | 20 ++++++----- 4 files changed, 59 insertions(+), 42 deletions(-) diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index 0824f22..8025fe8 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -30,7 +30,7 @@ cm auth logout --user # Target a specific stored account; mismatched cm auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set) cm auth token view --user # Print the saved token for a specific stored account cm account [list|current|use |remove ] # Manage stored accounts; all support --json/--ndjson - # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"legacy"} + # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"token-only"} cm auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set cm workspaces # List available workspaces cm workspace use # Set current workspace diff --git a/src/commands/auth/auth.test.ts b/src/commands/auth/auth.test.ts index 189c443..87ba59b 100644 --- a/src/commands/auth/auth.test.ts +++ b/src/commands/auth/auth.test.ts @@ -140,11 +140,22 @@ describe('auth command', () => { storeMocks.getLastStorageResult.mockReset().mockReturnValue({ storage: 'secure-store' }) }) - it('saves the token via store.set with an empty-id account, trims whitespace, and confirms', async () => { - const program = createProgram() + it('prompts interactively, saves the trimmed token via store.set with an empty-id account, and confirms', async () => { + const originalIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) + const mockRl = { + question: vi.fn((_prompt: string, cb: (answer: string) => void) => { + cb(' some_token_123456789 ') + }), + close: vi.fn(), + _writeToOutput: vi.fn(), + } + mockCreateInterface.mockReturnValue(mockRl as unknown as Interface) + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - await program.parseAsync(['node', 'cm', 'auth', 'token', ' some_token_123456789 ']) + await createProgram().parseAsync(['node', 'cm', 'auth', 'token']) + expect(mockRl.question).toHaveBeenCalled() expect(storeMocks.set).toHaveBeenCalledWith( { id: '', label: '', authMode: 'unknown', authScope: '' }, 'some_token_123456789', @@ -153,23 +164,20 @@ describe('auth command', () => { expect(consoleSpy).toHaveBeenCalledWith( 'Token stored securely in the system credential manager', ) + writeSpy.mockRestore() + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }) }) it('lets store.set errors propagate unchanged', async () => { storeMocks.set.mockRejectedValue(new Error('Permission denied')) - const program = createProgram() - - await expect( - program.parseAsync(['node', 'cm', 'auth', 'token', 'some_token_123456789']), - ).rejects.toThrow('Permission denied') - }) - - it('prompts interactively when no token argument is given', async () => { const originalIsTTY = process.stdin.isTTY Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) const mockRl = { question: vi.fn((_prompt: string, cb: (answer: string) => void) => { - cb('interactive_token_456') + cb('some_token_123456789') }), close: vi.fn(), _writeToOutput: vi.fn(), @@ -177,13 +185,10 @@ describe('auth command', () => { mockCreateInterface.mockReturnValue(mockRl as unknown as Interface) const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - await createProgram().parseAsync(['node', 'cm', 'auth', 'token']) + await expect( + createProgram().parseAsync(['node', 'cm', 'auth', 'token']), + ).rejects.toThrow('Permission denied') - expect(mockRl.question).toHaveBeenCalled() - expect(storeMocks.set).toHaveBeenCalledWith( - expect.objectContaining({ id: '', authMode: 'unknown' }), - 'interactive_token_456', - ) writeSpy.mockRestore() Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, @@ -197,19 +202,30 @@ describe('auth command', () => { warning: 'system credential manager unavailable; token saved as plaintext in /home/user/.config/comms-cli/config.json', }) + const originalIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) + const mockRl = { + question: vi.fn((_prompt: string, cb: (answer: string) => void) => { + cb('some_token_123456789') + }), + close: vi.fn(), + _writeToOutput: vi.fn(), + } + mockCreateInterface.mockReturnValue(mockRl as unknown as Interface) + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - await createProgram().parseAsync([ - 'node', - 'cm', - 'auth', - 'token', - 'some_token_123456789', - ]) + await createProgram().parseAsync(['node', 'cm', 'auth', 'token']) expect(errorSpy).toHaveBeenCalledWith( 'Warning:', 'system credential manager unavailable; token saved as plaintext in /home/user/.config/comms-cli/config.json', ) + + writeSpy.mockRestore() + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }) }) it('throws NO_TOKEN without calling store.set when the input is empty (interactive + non-interactive)', async () => { diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index d55fd3e..e0571b4 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -19,15 +19,14 @@ export function registerAuthCommand(program: Command): void { attachCommsLogoutCommand(auth, refAware) attachCommsStatusCommand(auth, refAware) - // `token` is a hybrid: the positional `[token]` saves, and the `view` - // subcommand prints. Commander matches subcommand names before the parent - // action, so `cm auth token view` always dispatches to the view path — - // Comms OAuth tokens are opaque random strings so the literal "view" can - // never collide with a real token value. + // `token` is a hybrid: bare `cm auth token` prompts interactively to save + // a token, and the `view` subcommand prints it. Tokens are never accepted + // as positional/CLI arguments — that would leak them via process lists + // and shell history (Doist Secrets Management Standard). const tokenCmd = auth - .command('token [token]') + .command('token') .description('Save API token for CLI authentication (or use a subcommand: `view`)') - .action(loginWithToken) + .action(() => loginWithToken()) attachTokenViewCommand(tokenCmd, { name: 'view', diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 640526d..921889f 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -27,16 +27,18 @@ function promptHiddenInput(prompt: string): Promise { }) } -export async function loginWithToken(token?: string): Promise { - if (!token) { - if (isNonInteractive()) { - throw new CliError( - 'NO_TOKEN', - 'Cannot prompt for token in non-interactive mode. Set the COMMS_API_TOKEN environment variable instead.', - ) - } - token = await promptHiddenInput('API token: ') +// Tokens are read interactively or from the COMMS_API_TOKEN env var only — +// 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 { + if (isNonInteractive()) { + throw new CliError( + 'NO_TOKEN', + 'Cannot prompt for token in non-interactive mode. Set the COMMS_API_TOKEN environment variable instead.', + ) } + const token = await promptHiddenInput('API token: ') const trimmed = token.trim() if (!trimmed) { throw new CliError('NO_TOKEN', 'No token provided', [ From 3cff2711ac9d65ce28d808f9e64f1853956229d6 Mon Sep 17 00:00:00 2001 From: Amir Date: Wed, 20 May 2026 18:43:05 +0200 Subject: [PATCH 4/5] =?UTF-8?q?chore:=20rename=20`cm`=20=E2=86=92=20`tdc`?= =?UTF-8?q?=20and=20address=20Doistbot=20pass=203=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename: - bin: `cm` → `tdc` (package.json, package-lock.json) - env vars: `CM_ACCESSIBLE` → `TDC_ACCESSIBLE`, `CM_SPINNER` → `TDC_SPINNER` - every code/doc/test reference to the CLI command name. Doistbot pass 3 fixes: - Regenerate skills/comms-cli/SKILL.md from src/lib/skills/content.ts (was stale at v2.41.2; now v0.1.0-alpha.1 with token-only payload). - Move TTY/stdout cleanup in token-subcommand tests into afterEach so it runs even when an assertion fails mid-test. - Extract `mockPromptAnswer` helper to deduplicate readline mocking across token tests. - Add regression test that `tdc auth token ` is rejected and never reaches `store.set`, preventing argv-based secret entry from regressing. CI: each workflow now checks out `Doist/comms-sdk-typescript` as a sibling under $GITHUB_WORKSPACE so the `file:../comms-sdk-typescript` dep in package.json resolves on the runner. Main repo moves under `comms-cli/` and every step gets `working-directory: comms-cli`. Pre-existing bootstrap follow-ups (string-vs-number IDs, fullName/name, awayMode SDK shape) still cause type-check + 8 test failures and remain out of scope for this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .agents/skills/add-command/SKILL.md | 6 +- .github/workflows/check-skill-sync.yml | 16 +- .github/workflows/lint.yml | 15 +- .github/workflows/test.yml | 15 +- .gitignore | 1 + AGENTS.md | 8 +- README.md | 94 ++-- docs/SPEC.md | 108 ++-- docs/comms-search.md | 4 +- package-lock.json | 2 +- package.json | 2 +- skills/comms-cli/SKILL.md | 460 +++++++++--------- src/commands/account/account.test.ts | 42 +- src/commands/account/current.ts | 8 +- src/commands/account/index.ts | 6 +- src/commands/account/list.ts | 2 +- src/commands/auth/auth.test.ts | 177 ++++--- src/commands/auth/index.ts | 2 +- src/commands/auth/logout.ts | 2 +- src/commands/auth/status.ts | 6 +- src/commands/auth/store-wrap.ts | 4 +- src/commands/auth/token.ts | 4 +- src/commands/away/away.test.ts | 18 +- src/commands/away/index.ts | 14 +- src/commands/changelog.test.ts | 10 +- src/commands/channel/channel.test.ts | 32 +- src/commands/channel/index.ts | 26 +- src/commands/channel/threads.test.ts | 58 ++- src/commands/comment/comment.test.ts | 32 +- src/commands/comment/index.ts | 14 +- src/commands/completion/helpers.ts | 8 +- src/commands/completion/install.ts | 4 +- src/commands/completion/uninstall.ts | 4 +- src/commands/config/config.test.ts | 72 +-- src/commands/config/index.ts | 12 +- src/commands/config/view.ts | 2 +- .../conversation/conversation.test.ts | 52 +- src/commands/conversation/index.ts | 32 +- src/commands/doctor.test.ts | 16 +- src/commands/doctor.ts | 2 +- src/commands/groups/groups.test.ts | 74 +-- src/commands/groups/index.ts | 36 +- src/commands/inbox.test.ts | 16 +- src/commands/inbox.ts | 12 +- src/commands/mentions.test.ts | 10 +- src/commands/mentions.ts | 6 +- src/commands/msg/index.ts | 14 +- src/commands/msg/msg.test.ts | 12 +- src/commands/react.test.ts | 10 +- src/commands/react.ts | 14 +- src/commands/search.test.ts | 6 +- src/commands/search.ts | 8 +- src/commands/skill/install.ts | 2 +- src/commands/skill/skill.test.ts | 20 +- src/commands/skill/uninstall.ts | 2 +- src/commands/skill/update.ts | 2 +- src/commands/thread/index.ts | 44 +- src/commands/thread/thread.test.ts | 136 +++--- src/commands/update/index.ts | 2 +- src/commands/update/update.test.ts | 10 +- src/commands/user.test.ts | 10 +- src/commands/user.ts | 8 +- src/commands/view.test.ts | 14 +- src/commands/view.ts | 22 +- src/commands/workspace.ts | 8 +- src/index.ts | 4 +- src/lib/api.ts | 2 +- src/lib/auth-pages.ts | 8 +- src/lib/auth-provider.ts | 6 +- src/lib/auth.ts | 8 +- src/lib/completion.test.ts | 6 +- src/lib/completion.ts | 2 +- src/lib/config.test.ts | 6 +- src/lib/config.ts | 8 +- src/lib/global-args.test.ts | 74 +-- src/lib/global-args.ts | 8 +- src/lib/input.test.ts | 4 +- src/lib/output.test.ts | 16 +- src/lib/permissions.ts | 4 +- src/lib/progress.test.ts | 42 +- src/lib/public-channels.test.ts | 8 +- src/lib/refs.ts | 20 +- src/lib/skills/content.ts | 458 ++++++++--------- src/lib/skills/create-installer.ts | 4 +- src/lib/update.ts | 4 +- 85 files changed, 1325 insertions(+), 1257 deletions(-) diff --git a/.agents/skills/add-command/SKILL.md b/.agents/skills/add-command/SKILL.md index 4c485a9..34b0aab 100644 --- a/.agents/skills/add-command/SKILL.md +++ b/.agents/skills/add-command/SKILL.md @@ -19,7 +19,7 @@ Color convention: ## 2. 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 (`cm auth login --read-only`). +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 (`tdc auth login --read-only`). - **Read-only methods** (fetch/list/view): add to `KNOWN_SAFE_API_METHODS` - **Mutating methods** (create/update/delete/archive/mute): do NOT add — they are blocked by default, which is the correct behavior @@ -89,7 +89,7 @@ The variable assignment (`const myCmd = ...`) is needed so the `.action()` callb ### Implicit view subcommand -For entity commands with a `view` subcommand, mark it as the default so `cm thread 123` maps to `cm thread view 123`: +For entity commands with a `view` subcommand, mark it as the default so `tdc thread 123` maps to `tdc thread view 123`: ```typescript thread @@ -128,7 +128,7 @@ const commands: Record Promise<(p: Command) => void>]> = ## 4. Accessibility (`src/lib/output.ts`) -The CLI supports accessible mode via `isAccessible()` (checks `CM_ACCESSIBLE=1` or `--accessible` flag). When adding output that uses color or visual elements, consider whether information is conveyed **only** by color or decoration. +The CLI supports accessible mode via `isAccessible()` (checks `TDC_ACCESSIBLE=1` or `--accessible` flag). When adding output that uses color or visual elements, consider whether information is conveyed **only** by color or decoration. ### When to add accessible alternatives diff --git a/.github/workflows/check-skill-sync.yml b/.github/workflows/check-skill-sync.yml index 94413e3..c2491fe 100644 --- a/.github/workflows/check-skill-sync.yml +++ b/.github/workflows/check-skill-sync.yml @@ -12,25 +12,39 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: + - name: Checkout Comms SDK (sibling dep via file:../comms-sdk-typescript) + uses: actions/checkout@v5 + with: + repository: Doist/comms-sdk-typescript + path: comms-sdk-typescript + token: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout uses: actions/checkout@v5 + with: + path: comms-cli - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version-file: '.nvmrc' + node-version-file: comms-cli/.nvmrc cache: 'npm' + cache-dependency-path: comms-cli/package-lock.json - name: Install dependencies + working-directory: comms-cli run: npm ci - name: Build + working-directory: comms-cli run: npm run build - name: Check SKILL.md is in sync + working-directory: comms-cli run: npm run check:skill-sync - name: Validate SKILL.md against agentskills.io spec + working-directory: comms-cli run: | if ! gh skill --help >/dev/null 2>&1; then echo "::notice::gh skill subcommand not available on this runner (requires gh >= 2.90.0); skipping validation" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2040445..0df6415 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,20 +12,33 @@ jobs: timeout-minutes: 10 steps: + - name: Checkout Comms SDK (sibling dep via file:../comms-sdk-typescript) + uses: actions/checkout@v5 + with: + repository: Doist/comms-sdk-typescript + path: comms-sdk-typescript + token: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout uses: actions/checkout@v5 + with: + path: comms-cli - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version-file: '.nvmrc' + node-version-file: comms-cli/.nvmrc cache: 'npm' + cache-dependency-path: comms-cli/package-lock.json - name: Install dependencies + working-directory: comms-cli run: npm ci - name: Type check + working-directory: comms-cli run: npm run type-check - name: Lint & format check + working-directory: comms-cli run: npm run lint:check diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5e3a9de..03721a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,20 +12,33 @@ jobs: timeout-minutes: 10 steps: + - name: Checkout Comms SDK (sibling dep via file:../comms-sdk-typescript) + uses: actions/checkout@v5 + with: + repository: Doist/comms-sdk-typescript + path: comms-sdk-typescript + token: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout uses: actions/checkout@v5 + with: + path: comms-cli - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version-file: '.nvmrc' + node-version-file: comms-cli/.nvmrc cache: 'npm' + cache-dependency-path: comms-cli/package-lock.json - name: Install dependencies + working-directory: comms-cli run: npm ci - name: Build + working-directory: comms-cli run: npm run build - name: Run tests + working-directory: comms-cli run: npm test diff --git a/.gitignore b/.gitignore index fc5abe2..ab26531 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ *.tsbuildinfo .DS_Store .claude/settings.local.json +.claude/scheduled_tasks.lock diff --git a/AGENTS.md b/AGENTS.md index 8395853..f6f3df9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ node dist/index.js ## Architecture -This is a TypeScript CLI (`cm`) for Comms messaging, built with Commander.js. +This is a TypeScript CLI (`tdc`) for Comms messaging, built with Commander.js. **Entry point**: `src/index.ts` registers all commands with Commander. @@ -56,7 +56,7 @@ This is a TypeScript CLI (`cm`) for Comms messaging, built with Commander.js. ## Key Patterns -- **Implicit view subcommand**: `cm thread ` defaults to `cm thread view ` via Commander's `{ isDefault: true }`. Same for `conversation` and `msg`. Edge case: if a ref matches a subcommand name (e.g., "reply"), the subcommand wins — user must use `cm thread view reply` +- **Implicit view subcommand**: `tdc thread ` defaults to `tdc thread view ` via Commander's `{ isDefault: true }`. Same for `conversation` and `msg`. Edge case: if a ref matches a subcommand name (e.g., "reply"), the subcommand wins — user must use `tdc thread view reply` - **Named flag aliases**: Where commands accept positional `[workspace-ref]`, the `--workspace` flag is also accepted. Error if both positional and flag are provided - **JSON output on mutating commands**: Mutating commands (create, update, delete, archive) should support `--json` output where it provides scripting value. Commands that return an object from the API (create/update) should also support `--full`. Commands where the API returns void should output a minimal status object (e.g. `{ id, deleted: true }` or `{ id, isArchived: true }`). Extend `MutationOptions` in `src/lib/options.ts` (which already includes `json` and `full`) rather than adding these fields ad hoc. Use `formatJson()` from `src/lib/output.ts` for the output. See `src/commands/away.ts` as the reference implementation. - **Spinner messages**: When adding new SDK method calls, add a corresponding entry in the `API_SPINNER_MESSAGES` map in `src/lib/api.ts`. Every user-facing API call should have a spinner message so the CLI shows progress feedback. @@ -87,7 +87,7 @@ Lefthook runs type-check, oxlint, and oxfmt on pre-commit, tests on pre-push. ## Skill Content (Agent Command Reference) -The file `src/lib/skills/content.ts` exports `SKILL_CONTENT` — a comprehensive command reference that gets installed into AI agent skill directories via `cm skill install`. This is the source of truth that agents use to understand available CLI commands. +The file `src/lib/skills/content.ts` exports `SKILL_CONTENT` — a comprehensive command reference that gets installed into AI agent skill directories via `tdc skill install`. This is the source of truth that agents use to understand available CLI commands. **Whenever commands, subcommands, flags, or options are added, updated, or removed in `src/commands/`, the `SKILL_CONTENT` in `src/lib/skills/content.ts` must be updated to match.** This includes: @@ -99,6 +99,6 @@ The file `src/lib/skills/content.ts` exports `SKILL_CONTENT` — a comprehensive After updating `SKILL_CONTENT`: 1. Run `npm run build && npm run sync:skill` to regenerate `skills/comms-cli/SKILL.md` (the standalone skill file used by `npx skills add`) -2. Run `cm skill update claude-code` (and any other installed agents) to propagate changes to installed skill files +2. Run `tdc skill update claude-code` (and any other installed agents) to propagate changes to installed skill files A CI check (`npm run check:skill-sync`) runs on pull requests and will fail if `skills/comms-cli/SKILL.md` is out of sync with `content.ts`. diff --git a/README.md b/README.md index d5e0d61..daa9460 100644 --- a/README.md +++ b/README.md @@ -17,19 +17,19 @@ A command-line interface for Comms. Install skills for your coding agent: ```bash -cm skill install claude-code -cm skill install codex -cm skill install cursor -cm skill install gemini -cm skill install pi -cm skill install universal +tdc skill install claude-code +tdc skill install codex +tdc skill install cursor +tdc skill install gemini +tdc skill install pi +tdc skill install universal ``` Skills are installed to `~//skills/comms-cli/SKILL.md` (e.g. `~/.claude/` for claude-code, `~/.agents/` for universal, etc.). When updating the CLI, installed skills are updated automatically. The `universal` agent is compatible with Amp, OpenCode, and other agents that read from `~/.agents/`. ```bash -cm skill list -cm skill uninstall +tdc skill list +tdc skill uninstall ``` ## Uninstallation @@ -37,7 +37,7 @@ cm skill uninstall First, remove any installed agent skills: ```bash -cm skill uninstall +tdc skill uninstall ``` Then uninstall the CLI: @@ -56,12 +56,12 @@ npm run build npm link ``` -This makes the `cm` command available globally. +This makes the `tdc` command available globally. ## Setup ```bash -cm auth login +tdc auth login ``` This opens your browser to authenticate with Comms. Once approved, the token is stored in your OS credential manager: @@ -77,7 +77,7 @@ If secure storage is unavailable, the CLI warns and falls back to `~/.config/com **Manual token:** ```bash -cm auth token +tdc auth token ``` The CLI prompts for the token without echoing it. Do **not** pass the token as a positional argument — it would be visible in `ps` / shell history. @@ -93,58 +93,58 @@ export COMMS_API_TOKEN="your-token" ### Auth commands ```bash -cm auth status # check if authenticated -cm auth logout # remove saved token +tdc auth status # check if authenticated +tdc auth logout # remove saved token ``` ## Usage ```bash -cm inbox # inbox threads -cm inbox --unread # unread threads only -cm mentions # content mentioning you -cm mentions --since 2026-04-01 --all --json -cm thread view # view thread with comments -cm thread view --comment 123 # view a specific comment -cm thread reply # reply to a thread -cm thread rename "New title" # rename a thread -cm thread update "New body" # edit a thread's body (first post) -cm conversation unread # list unread conversations -cm conversation view # view conversation messages -cm msg view # view a conversation message -cm search "keyword" # search across workspace -cm search "keyword" --all # fetch all result pages -cm react thread 👍 # add reaction -cm away # show away status -cm away set vacation 2026-03-20 # set away until date -cm away clear # clear away status -cm groups # list groups in a workspace -cm groups view # show a group with members -cm groups create "Frontend" # create a group -cm groups create "FE" --users alice@doist.com,bob@doist.com -cm groups rename "New name" # rename a group -cm groups delete --yes # delete a group -cm groups add-user alice@doist.com bob@doist.com -cm groups remove-user id:123,id:456 +tdc inbox # inbox threads +tdc inbox --unread # unread threads only +tdc mentions # content mentioning you +tdc mentions --since 2026-04-01 --all --json +tdc thread view # view thread with comments +tdc thread view --comment 123 # view a specific comment +tdc thread reply # reply to a thread +tdc thread rename "New title" # rename a thread +tdc thread update "New body" # edit a thread's body (first post) +tdc conversation unread # list unread conversations +tdc conversation view # view conversation messages +tdc msg view # view a conversation message +tdc search "keyword" # search across workspace +tdc search "keyword" --all # fetch all result pages +tdc react thread 👍 # add reaction +tdc away # show away status +tdc away set vacation 2026-03-20 # set away until date +tdc away clear # clear away status +tdc groups # list groups in a workspace +tdc groups view # show a group with members +tdc groups create "Frontend" # create a group +tdc groups create "FE" --users alice@doist.com,bob@doist.com +tdc groups rename "New name" # rename a group +tdc groups delete --yes # delete a group +tdc groups add-user alice@doist.com bob@doist.com +tdc groups remove-user id:123,id:456 ``` References accept IDs (`123` or `id:123`), Comms URLs, or fuzzy names (for workspaces/users). -Run `cm --help` or `cm --help` for more options. +Run `tdc --help` or `tdc --help` for more options. ## Shell Completions Tab completion is available for bash, zsh, and fish: ```bash -cm completion install # prompts for shell -cm completion install bash # or: zsh, fish +tdc completion install # prompts for shell +tdc completion install bash # or: zsh, fish ``` Restart your shell or source your config file to activate. To remove: ```bash -cm completion uninstall +tdc completion uninstall ``` ## Machine-readable output @@ -152,9 +152,9 @@ cm completion uninstall All list/view commands support `--json` and `--ndjson` flags for scripting: ```bash -cm inbox --json # JSON array -cm inbox --ndjson # newline-delimited JSON -cm inbox --json --full # include all fields +tdc inbox --json # JSON array +tdc inbox --ndjson # newline-delimited JSON +tdc inbox --json --full # include all fields ``` ## Development diff --git a/docs/SPEC.md b/docs/SPEC.md index ed8dcda..72aae9e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -43,7 +43,7 @@ __tests__/ # Test suite ## Package & Binary - **Package name**: `@doist/comms-cli` -- **Binary**: `cm` +- **Binary**: `tdc` ## Authentication @@ -58,7 +58,7 @@ Token resolution (priority order): Commands that require a workspace context use this resolution order: 1. `--workspace ` flag (if provided) -2. Config-stored current workspace (`cm workspace use `) +2. Config-stored current workspace (`tdc workspace use `) 3. User's default workspace from API (auto-stored to config on first use) --- @@ -67,7 +67,7 @@ Commands that require a workspace context use this resolution order: ### Workspace Commands -#### `cm workspaces` +#### `tdc workspaces` List all workspaces the user belongs to. @@ -75,7 +75,7 @@ Options: - `--json` / `--ndjson` - Machine-readable output -#### `cm workspace use ` +#### `tdc workspace use ` Set the current workspace for subsequent commands. @@ -87,11 +87,11 @@ Arguments: ### User Commands -#### `cm user` +#### `tdc user` Display current user info (name, email, timezone, default workspace). -#### `cm users [workspace-ref]` +#### `tdc users [workspace-ref]` List users in a workspace. @@ -108,7 +108,7 @@ Options: ### Channel Commands -#### `cm channels [workspace-ref]` +#### `tdc channels [workspace-ref]` List channels in a workspace. @@ -124,7 +124,7 @@ Options: ### Inbox Commands -#### `cm inbox [workspace-ref]` +#### `tdc inbox [workspace-ref]` Show inbox threads (mirrors Comms UI inbox - threads only, not DMs). @@ -150,7 +150,7 @@ Output format (human-readable): ### Thread Commands -#### `cm thread view ` +#### `tdc thread view ` Display a thread with its comments. @@ -171,7 +171,7 @@ Output: - Full thread content with markdown rendered (unless `--raw`) - Comments with full content (detail view = no truncation) -#### `cm thread reply [content]` +#### `tdc thread reply [content]` Post a comment to a thread. @@ -182,7 +182,7 @@ Arguments: Content input priority: -1. Stdin (if piped: `echo "text" | cm thread reply id:123`) +1. Stdin (if piped: `echo "text" | tdc thread reply id:123`) 2. Argument (if provided) 3. Opens `$EDITOR` (if neither stdin nor argument) @@ -194,7 +194,7 @@ Output: - Minimal confirmation with comment-specific URL -#### `cm thread done ` +#### `tdc thread done ` Archive a thread (mark as done). @@ -212,7 +212,7 @@ Options: Alias: `convo`. Conversations are DM/group containers. -#### `cm conversation unread [workspace-ref]` +#### `tdc conversation unread [workspace-ref]` List unread conversations. @@ -230,7 +230,7 @@ Output format: - URL on second line - No message preview (privacy) -#### `cm conversation view ` +#### `tdc conversation view ` Display a conversation with its messages. @@ -246,7 +246,7 @@ Options: - `--raw` - Show raw markdown instead of rendered - `--json` / `--ndjson` - Machine-readable output -#### `cm conversation reply [content]` +#### `tdc conversation reply [content]` Send a message in a conversation. @@ -255,7 +255,7 @@ Arguments: - `conversation-ref` - Conversation ID or Comms URL - `content` - Message content (optional if using stdin or editor) -Content input: Same as `cm thread reply` (stdin → arg → $EDITOR) +Content input: Same as `tdc thread reply` (stdin → arg → $EDITOR) Options: @@ -265,7 +265,7 @@ Output: - Minimal confirmation with message-specific URL -#### `cm conversation done ` +#### `tdc conversation done ` Archive a conversation. @@ -283,7 +283,7 @@ Options: Alias: `message`. Operations on individual messages within conversations. -#### `cm msg view ` +#### `tdc msg view ` View a single conversation message. @@ -296,7 +296,7 @@ Options: - `--raw` - Show raw markdown instead of rendered - `--json` / `--ndjson` - Machine-readable output -#### `cm msg update [content]` +#### `tdc msg update [content]` Edit a conversation message. @@ -305,13 +305,13 @@ Arguments: - `message-ref` - Message ID or Comms URL - `content` - New message content (optional if using stdin or editor) -Content input: Same as `cm thread reply` (stdin → arg → $EDITOR) +Content input: Same as `tdc thread reply` (stdin → arg → $EDITOR) Options: - `--dry-run` - Show what would be updated without updating -#### `cm msg delete ` +#### `tdc msg delete ` Delete a conversation message. @@ -327,7 +327,7 @@ Options: ### Search Commands -#### `cm search [workspace-ref]` +#### `tdc search [workspace-ref]` Search content across a workspace. @@ -351,7 +351,7 @@ Options: ### Reaction Commands -#### `cm react ` +#### `tdc react ` Add an emoji reaction. @@ -367,13 +367,13 @@ Options: Output displays actual emoji character. -#### `cm unreact ` +#### `tdc unreact ` Remove an emoji reaction. Arguments: -- Same as `cm react` +- Same as `tdc react` Options: @@ -482,63 +482,63 @@ Location: `~/.config/comms-cli/config.json` ```bash # Set current workspace -cm workspace use "My Team" +tdc workspace use "My Team" # View inbox -cm inbox -cm inbox --unread +tdc inbox +tdc inbox --unread # View a thread -cm thread view id:123456 -cm thread view https://comms.todoist.com/a/12345/ch/67890/t/123456 +tdc thread view id:123456 +tdc thread view https://comms.todoist.com/a/12345/ch/67890/t/123456 # Reply to a thread -cm thread reply id:123456 "Great idea!" -echo "Multiline\nreply" | cm thread reply id:123456 -cm thread reply id:123456 # opens $EDITOR +tdc thread reply id:123456 "Great idea!" +echo "Multiline\nreply" | tdc thread reply id:123456 +tdc thread reply id:123456 # opens $EDITOR # Mark thread as done -cm thread done id:123456 +tdc thread done id:123456 # List unread conversations -cm conversation unread +tdc conversation unread # View and reply to a conversation -cm conversation view id:456789 -cm conversation reply id:456789 "Thanks!" +tdc conversation view id:456789 +tdc conversation reply id:456789 "Thanks!" # Search -cm search "quarterly report" -cm search "bug fix" --author id:123 --since 2024-01-01 +tdc search "quarterly report" +tdc search "bug fix" --author id:123 --since 2024-01-01 # React to content -cm react thread id:123456 +1 -cm react comment id:789 👍 -cm unreact message id:456 heart +tdc react thread id:123456 +1 +tdc react comment id:789 👍 +tdc unreact message id:456 heart # List channels and users -cm channels -cm users --search "john" +tdc channels +tdc users --search "john" # Dry run before mutating -cm thread reply id:123 "test" --dry-run -cm thread done id:123 --dry-run +tdc thread reply id:123 "test" --dry-run +tdc thread done id:123 --dry-run # JSON output for scripting -cm inbox --json -cm search "project" --ndjson +tdc inbox --json +tdc search "project" --ndjson ``` --- ## Not in MVP (Future Considerations) -- `cm conversation start` - Start new conversations -- `cm thread done --all` - Bulk archive -- `cm link` command - URLs shown in output instead -- `cm open` - Open in browser -- `cm star` / `cm mute` - Star/mute content -- `cm unread` - Unified unread view (threads + messages) +- `tdc conversation start` - Start new conversations +- `tdc thread done --all` - Bulk archive +- `tdc link` command - URLs shown in output instead +- `tdc open` - Open in browser +- `tdc star` / `tdc mute` - Star/mute content +- `tdc unread` - Unified unread view (threads + messages) --- diff --git a/docs/comms-search.md b/docs/comms-search.md index da8f80a..5c7e3af 100644 --- a/docs/comms-search.md +++ b/docs/comms-search.md @@ -5,13 +5,13 @@ Currently `--author` and `--to` require numeric user IDs: ```bash -cm search "test" --author 440929 +tdc search "test" --author 440929 ``` Users should be able to pass names: ```bash -cm search "test" --author craig +tdc search "test" --author craig ``` ## Behavior diff --git a/package-lock.json b/package-lock.json index 8638ed6..3039d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "open": "11.0.0" }, "bin": { - "cm": "dist/index.js" + "tdc": "dist/index.js" }, "devDependencies": { "@semantic-release/changelog": "6.0.3", diff --git a/package.json b/package.json index 954d979..6dde66b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "dist/index.js", "bin": { - "cm": "dist/index.js" + "tdc": "dist/index.js" }, "scripts": { "build": "tsc -p tsconfig.build.json && chmod +x dist/index.js", diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index 8025fe8..c7cc4ff 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -4,119 +4,119 @@ description: "Comms messaging CLI. View and respond to inbox threads, channel th license: MIT metadata: author: Doist - version: "2.41.2" + version: "0.1.0-alpha.1" --- -# Comms CLI (cm) +# Comms CLI (tdc) -Access Comms messaging via the `cm` CLI. Use when the user asks about their Comms workspaces, threads, messages, or wants to interact with Comms in any way. +Access Comms messaging via the `tdc` CLI. Use when the user asks about their Comms workspaces, threads, messages, or wants to interact with Comms in any way. ## Setup ```bash -cm auth login # OAuth login (opens browser, read-write) -cm auth login --read-only # OAuth login with read-only scope -cm auth login --callback-port # Override the local OAuth callback port (default 8766) -cm auth login --json # Emit a JSON envelope for scripted / agent use -cm auth login --ndjson # Emit an NDJSON envelope for scripted / agent use -cm auth token # Save API token manually (prompts securely; scope unknown, assumed write-capable) -cm auth status # Verify authentication + show mode -cm auth status --json # Full status payload as JSON (--ndjson also supported) -cm auth status --user # Target a specific stored account (id, id:, or display name) -cm --user auth # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it -cm auth logout # Remove saved token and auth metadata -cm auth logout --json # Emits `{"ok": true}` (--ndjson is silent) -cm auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND -cm auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set) -cm auth token view --user # Print the saved token for a specific stored account -cm account [list|current|use |remove ] # Manage stored accounts; all support --json/--ndjson +tdc auth login # OAuth login (opens browser, read-write) +tdc auth login --read-only # OAuth login with read-only scope +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 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) +tdc --user auth # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it +tdc auth logout # Remove saved token and auth metadata +tdc auth logout --json # Emits `{"ok": true}` (--ndjson is silent) +tdc auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND +tdc auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set) +tdc auth token view --user # Print the saved token for a specific stored account +tdc account [list|current|use |remove ] # Manage stored accounts; all support --json/--ndjson # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"token-only"} -cm auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set -cm workspaces # List available workspaces -cm workspace use # Set current workspace -cm completion install # Install shell completions -cm config view # Show the current CLI configuration file (token masked) -cm config set # Set a user preference (e.g. unarchive-new-threads true) -cm doctor # Diagnose CLI setup and environment issues -cm update # Update CLI to latest version -cm changelog # Show recent changelog entries +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 workspace use # Set current workspace +tdc completion install # Install shell completions +tdc config view # Show the current CLI configuration file (token masked) +tdc config set # Set a user preference (e.g. unarchive-new-threads true) +tdc doctor # Diagnose CLI setup and environment issues +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, `cm` warns and falls back to `~/.config/comms-cli/config.json`. `COMMS_API_TOKEN` always takes priority over the stored token, and legacy plaintext config tokens are migrated automatically when secure storage is available. +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 (`cm 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 `cm auth token`) are treated as unknown scope and assumed write-capable. +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. ## View by URL ```bash -cm view # View any Comms entity by URL +tdc view # View any Comms entity by URL ``` Routes automatically based on URL structure: -- Message URL → `cm msg view` -- Conversation URL → `cm conversation view` -- Thread+comment URL → `cm thread view` (comment ID extracted from URL) -- Thread URL → `cm thread view` +- Message URL → `tdc msg view` +- Conversation URL → `tdc conversation view` +- Thread+comment URL → `tdc thread view` (comment ID extracted from URL) +- Thread URL → `tdc thread view` All target command flags pass through (e.g. `--json`, `--raw`, `--full`). ## Inbox ```bash -cm inbox # Show inbox threads -cm inbox --unread # Only unread threads -cm inbox --archive-filter all # Show active + done threads -cm inbox --archive-filter archived # Show only done threads -cm inbox --channel # Filter by channel name (fuzzy) -cm inbox --since # Filter by date (ISO format) -cm inbox --limit # Max items (default: 50) +tdc inbox # Show inbox threads +tdc inbox --unread # Only unread threads +tdc inbox --archive-filter all # Show active + done threads +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) ``` ## Threads ```bash -cm thread # View thread (shorthand for view) -cm thread view # View thread with comments -cm thread view --comment # View a specific comment -cm thread view # Comment ID extracted from URL -cm thread view --unread # Show only unread comments -cm thread view --context 3 # Include 3 read comments before unread -cm thread view --limit 20 # Limit number of comments -cm thread view --since # Comments newer than date -cm thread view --raw # Show raw markdown -cm thread create "Title" "content" # Create a new thread -cm thread create "Title" "content" --json # Create and return as JSON -cm thread create "Title" "content" --json --full # Include all thread fields -cm thread create "Title" "content" --notify 123,456 # Notify specific users -cm thread create "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive) -cm thread create "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true -cm thread create "Title" "content" --dry-run # Preview without posting -cm thread reply "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) -cm thread reply "content" --notify EVERYONE # Notify all workspace members -cm thread reply "content" --notify 123,id:456 # Notify specific user IDs -cm thread reply "content" --json # Post and return comment as JSON -cm thread reply "content" --json --full # Include all comment fields -cm thread reply "content" --close # Reply and close the thread -cm thread reply "content" --reopen # Reply and reopen a closed thread -cm thread done # Archive thread (mark done) -cm thread done --json # Archive and return status as JSON -cm thread mute # Mute thread for 60 minutes (default) -cm thread mute --minutes 480 # Mute for custom duration -cm thread mute --json # Mute and return { id, mutedUntil } as JSON -cm thread mute --json --full # Mute and return full thread as JSON -cm thread unmute # Unmute a muted thread -cm thread unmute --json # Unmute and return { id, mutedUntil } as JSON -cm thread delete # Preview thread deletion (requires --yes to execute) -cm thread delete --yes # Permanently delete a thread -cm thread delete --yes --json # Delete and return status as JSON -cm thread rename "New title" # Rename a thread (change its title) -cm thread rename "New title" --json # Rename and return { id, title } as JSON -cm thread rename "New title" --json --full # Rename and return full thread as JSON -cm thread update "New body" # Update a thread's body (the first post) -echo "New body" | cm thread update # Update body from stdin -cm thread update "New body" --dry-run # Preview without updating -cm thread update "New body" --json # Update and return { id, content } as JSON -cm thread update "New body" --json --full # Update and return full thread as JSON +tdc thread # View thread (shorthand for view) +tdc thread view # View thread with comments +tdc thread view --comment # View a specific comment +tdc thread view # Comment ID extracted from URL +tdc thread view --unread # Show only unread comments +tdc thread view --context 3 # Include 3 read comments before unread +tdc thread view --limit 20 # Limit number of comments +tdc thread view --since # Comments newer than date +tdc thread view --raw # Show raw markdown +tdc thread create "Title" "content" # Create a new thread +tdc thread create "Title" "content" --json # Create and return as JSON +tdc thread create "Title" "content" --json --full # Include all thread fields +tdc thread create "Title" "content" --notify 123,456 # Notify specific users +tdc thread create "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive) +tdc thread create "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true +tdc thread create "Title" "content" --dry-run # Preview without posting +tdc thread reply "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) +tdc thread reply "content" --notify EVERYONE # Notify all workspace members +tdc thread reply "content" --notify 123,id:456 # Notify specific user IDs +tdc thread reply "content" --json # Post and return comment as JSON +tdc thread reply "content" --json --full # Include all comment fields +tdc thread reply "content" --close # Reply and close the thread +tdc thread reply "content" --reopen # Reply and reopen a closed thread +tdc thread done # Archive thread (mark done) +tdc thread done --json # Archive and return status as JSON +tdc thread mute # Mute thread for 60 minutes (default) +tdc thread mute --minutes 480 # Mute for custom duration +tdc thread mute --json # Mute and return { id, mutedUntil } as JSON +tdc thread mute --json --full # Mute and return full thread as JSON +tdc thread unmute # Unmute a muted thread +tdc thread unmute --json # Unmute and return { id, mutedUntil } as JSON +tdc thread delete # Preview thread deletion (requires --yes to execute) +tdc thread delete --yes # Permanently delete a thread +tdc thread delete --yes --json # Delete and return status as JSON +tdc thread rename "New title" # Rename a thread (change its title) +tdc thread rename "New title" --json # Rename and return { id, title } as JSON +tdc thread rename "New title" --json --full # Rename and return full thread as JSON +tdc thread update "New body" # Update a thread's body (the first post) +echo "New body" | tdc thread update # Update body from stdin +tdc thread update "New body" --dry-run # Preview without updating +tdc thread update "New body" --json # Update and return { id, content } as JSON +tdc thread update "New body" --json --full # Update and return full thread as JSON ``` Default `--notify` for reply is EVERYONE_IN_THREAD, which may notify more people than intended. Before posting, confirm with the user whether specific people should be notified instead (via `--notify `). Options: EVERYONE, EVERYONE_IN_THREAD, or comma-separated ID refs. @@ -126,141 +126,141 @@ Default `--notify` for reply is EVERYONE_IN_THREAD, which may notify more people ## Thread Comments ```bash -cm comment # View a comment (shorthand for view) -cm comment view # View a single thread comment -cm comment view --raw # Show raw markdown -cm comment view --json # Output as JSON -cm comment view --ndjson # Output as newline-delimited JSON -cm comment view --json --full # Include all fields in JSON output -cm comment update "new content" # Update a thread comment -cm comment update "content" --json # Update and return updated comment as JSON -cm comment update "content" --json --full # Include all comment fields -cm comment delete # Delete a thread comment -cm comment delete --json # Delete and return status as JSON +tdc comment # View a comment (shorthand for view) +tdc comment view # View a single thread comment +tdc comment view --raw # Show raw markdown +tdc comment view --json # Output as JSON +tdc comment view --ndjson # Output as newline-delimited JSON +tdc comment view --json --full # Include all fields in JSON output +tdc comment update "new content" # Update a thread comment +tdc comment update "content" --json # Update and return updated comment as JSON +tdc comment update "content" --json --full # Include all comment fields +tdc comment delete # Delete a thread comment +tdc comment delete --json # Delete and return status as JSON ``` ## Conversations (DMs/Groups) ```bash -cm conversation unread # List unread conversations -cm conversation # View conversation (shorthand for view) -cm conversation view # View conversation messages -cm conversation with # Find your 1:1 DM with a user -cm conversation with --snippet # Include the latest message preview -cm conversation with --include-groups # List any conversations with that user -cm conversation reply "content" # Send a message -cm conversation reply "content" --json # Send and return message as JSON -cm conversation reply "content" --json --full # Include all message fields -cm conversation done # Archive conversation -cm conversation done --json # Archive and return status as JSON -cm conversation mute # Mute conversation for 60 minutes (default) -cm conversation mute --minutes 480 # Mute for custom duration -cm conversation mute --json # Mute and return { id, mutedUntil } as JSON -cm conversation mute --json --full # Mute and return full conversation as JSON -cm conversation unmute # Unmute a muted conversation -cm conversation unmute --json # Unmute and return { id, mutedUntil } as JSON +tdc conversation unread # List unread conversations +tdc conversation # View conversation (shorthand for view) +tdc conversation view # View conversation messages +tdc conversation with # Find your 1:1 DM with a user +tdc conversation with --snippet # Include the latest message preview +tdc conversation with --include-groups # List any conversations with that user +tdc conversation reply "content" # Send a message +tdc conversation reply "content" --json # Send and return message as JSON +tdc conversation reply "content" --json --full # Include all message fields +tdc conversation done # Archive conversation +tdc conversation done --json # Archive and return status as JSON +tdc conversation mute # Mute conversation for 60 minutes (default) +tdc conversation mute --minutes 480 # Mute for custom duration +tdc conversation mute --json # Mute and return { id, mutedUntil } as JSON +tdc conversation mute --json --full # Mute and return full conversation as JSON +tdc conversation unmute # Unmute a muted conversation +tdc conversation unmute --json # Unmute and return { id, mutedUntil } as JSON ``` -Alias: `cm convo` works the same as `cm conversation`. +Alias: `tdc convo` works the same as `tdc conversation`. ## Conversation Messages ```bash -cm msg # View a message (shorthand for view) -cm msg view # View a single conversation message -cm msg update "content" # Edit a conversation message -cm msg update "content" --json # Edit and return updated message as JSON -cm msg update "content" --json --full # Include all message fields -cm msg delete # Delete a conversation message -cm msg delete --json # Delete and return status as JSON +tdc msg # View a message (shorthand for view) +tdc msg view # View a single conversation message +tdc msg update "content" # Edit a conversation message +tdc msg update "content" --json # Edit and return updated message as JSON +tdc msg update "content" --json --full # Include all message fields +tdc msg delete # Delete a conversation message +tdc msg delete --json # Delete and return status as JSON ``` -Alias: `cm message` works the same as `cm msg`. +Alias: `tdc message` works the same as `tdc msg`. ## Search ```bash -cm mentions # Show content mentioning current user -cm mentions --since 2026-04-01 --all # Fetch every mention since a date -cm mentions --type threads --json # Limit mentions to threads -cm search "query" # Search content -cm search "query" --type threads # Filter: threads, messages, or all -cm search "query" --author # Filter by author -cm search "query" --to # Messages sent to user -cm search "query" --title-only # Search thread titles only -cm search "query" --mention-me # Results mentioning current user -cm search "query" --conversation # Limit to conversations (comma-separated refs) -cm search "query" --since # Content from date -cm search "query" --until # Content until date -cm search "query" --channel # Filter by channel refs (comma-separated) -cm search "query" --limit # Max results (default: 50) -cm search "query" --cursor # Pagination cursor -cm search "query" --all # Fetch all result pages +tdc mentions # Show content mentioning current user +tdc mentions --since 2026-04-01 --all # Fetch every mention since a date +tdc mentions --type threads --json # Limit mentions to threads +tdc search "query" # Search content +tdc search "query" --type threads # Filter: threads, messages, or all +tdc search "query" --author # Filter by author +tdc search "query" --to # Messages sent to user +tdc search "query" --title-only # Search thread titles only +tdc search "query" --mention-me # Results mentioning current user +tdc search "query" --conversation # Limit to conversations (comma-separated refs) +tdc search "query" --since # Content from date +tdc search "query" --until # Content until date +tdc search "query" --channel # Filter by channel refs (comma-separated) +tdc search "query" --limit # Max results (default: 50) +tdc search "query" --cursor # Pagination cursor +tdc search "query" --all # Fetch all result pages ``` ## Users, Channels & Groups ```bash -cm user # Show current user info -cm user --json # JSON output -cm user --json --full # Include all fields in JSON output -cm users # List workspace users -cm users --search # Filter by name/email -cm channels # List active joined workspace channels (alias of: cm channel list) -cm channels --state all # Include archived joined channels too -cm channels --scope discoverable # Active public channels you can see but have not joined -cm channels --scope public --state all --json # All visible public channels, with joined status -cm channel threads # List threads in a channel (fuzzy name, id:, numeric ID, or URL) -cm channel threads "general" --unread # Only unread threads -cm channel threads --archive-filter all # Include archived threads (active|archived|all) -cm channel threads --since 2026-01-01 # Filter by last-updated date (ISO) -cm channel threads --limit 20 # Max threads per page (default: 50) -cm channel threads --limit 20 --cursor # Paginate -cm channel threads --json # { results, nextCursor } with isUnread + url -cm groups # List workspace groups -cm groups --search "frontend" # Filter groups by name (case-insensitive) -cm groups --json # JSON output -cm groups --json --full # Include all fields in JSON output -cm groups view # Show group with member details -cm groups view --json # JSON output with id, name, workspaceId, members -cm groups view --json --full # Include all fields in JSON output -cm groups create "Name" # Create a new group -cm groups create "Name" --users alice@doist.com,bob@doist.com # Create with members -cm groups create "Name" --json # Output created group as JSON -cm groups rename "New name" # Rename a group -cm groups rename "Name" --json # Output renamed group as JSON -cm groups delete --yes # Delete a group (requires --yes) -cm groups delete --dry-run # Preview deletion -cm groups add-user user1 user2 # Add users to a group -cm groups add-user a@d.com,b@d.com # Comma-separated refs -cm groups add-user id:123 --json # Output result as JSON -cm groups remove-user user1 user2 # Remove users from a group -cm groups remove-user id:123,id:456 # Comma-separated ID refs +tdc user # Show current user info +tdc user --json # JSON output +tdc user --json --full # Include all fields in JSON output +tdc users # List workspace users +tdc users --search # Filter by name/email +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 channel threads # List threads in a channel (fuzzy name, id:, numeric ID, or URL) +tdc channel threads "general" --unread # Only unread threads +tdc channel threads --archive-filter all # Include archived threads (active|archived|all) +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 groups # List workspace groups +tdc groups --search "frontend" # Filter groups by name (case-insensitive) +tdc groups --json # JSON output +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 +tdc groups view --json --full # Include all fields in JSON output +tdc groups create "Name" # Create a new group +tdc groups create "Name" --users alice@doist.com,bob@doist.com # Create with members +tdc groups create "Name" --json # Output created group as JSON +tdc groups rename "New name" # Rename a group +tdc groups rename "Name" --json # Output renamed group as JSON +tdc groups delete --yes # Delete a group (requires --yes) +tdc groups delete --dry-run # Preview deletion +tdc groups add-user user1 user2 # Add users to a group +tdc groups add-user a@d.com,b@d.com # Comma-separated refs +tdc groups add-user id:123 --json # Output result as JSON +tdc groups remove-user user1 user2 # Remove users from a group +tdc groups remove-user id:123,id:456 # Comma-separated ID refs ``` -If a channel is not found in `cm channels`, widen with broader listings such as `cm channels --scope public`, then `cm channels --scope public --state all`. Check `cm channels --help` for other available filters. +If a channel is not found in `tdc channels`, widen with broader listings such as `tdc channels --scope public`, then `tdc channels --scope public --state all`. Check `tdc channels --help` for other available filters. -`cm channel threads` returns every thread in the channel; pagination filters (`--limit`, `--cursor`, `--since`, `--until`, `--unread`) are applied client-side after fetch. `--archive-filter` is applied server-side. Results are sorted newest-first by last activity. In `--json` / `--ndjson`, the response includes a `nextCursor` string (opaque) you can pass via `--cursor` to fetch the next page; NDJSON emits the cursor as a final `{ "_meta": true, "nextCursor": "..." }` line. +`tdc channel threads` returns every thread in the channel; pagination filters (`--limit`, `--cursor`, `--since`, `--until`, `--unread`) are applied client-side after fetch. `--archive-filter` is applied server-side. Results are sorted newest-first by last activity. In `--json` / `--ndjson`, the response includes a `nextCursor` string (opaque) you can pass via `--cursor` to fetch the next page; NDJSON emits the cursor as a final `{ "_meta": true, "nextCursor": "..." }` line. ## Away Status ```bash -cm away # Show current away status -cm away set [until] # Set away (type: vacation, parental, sickleave, other) -cm away set vacation 2026-03-20 # Away until March 20 -cm away set vacation 2026-03-20 --from 2026-03-15 # Custom start date -cm away clear # Clear away status +tdc away # Show current away status +tdc away set [until] # Set away (type: vacation, parental, sickleave, other) +tdc away set vacation 2026-03-20 # Away until March 20 +tdc away set vacation 2026-03-20 --from 2026-03-15 # Custom start date +tdc away clear # Clear away status ``` ## Reactions ```bash -cm react thread 👍 # Add reaction to thread -cm react comment +1 # Add reaction (shortcode) -cm react message heart # Add reaction to DM message -cm react thread 👍 --json # Output result as JSON -cm unreact thread 👍 # Remove reaction -cm unreact thread 👍 --json # Output result as JSON +tdc react thread 👍 # Add reaction to thread +tdc react comment +1 # Add reaction (shortcode) +tdc react message heart # Add reaction to DM message +tdc react thread 👍 --json # Output result as JSON +tdc unreact thread 👍 # Remove reaction +tdc unreact thread 👍 --json # Output result as JSON ``` Supported shortcodes: +1, -1, heart, tada, smile, laughing, thinking, fire, check, x, eyes, pray, clap, rocket, wave @@ -268,52 +268,52 @@ Supported shortcodes: +1, -1, heart, tada, smile, laughing, thinking, fire, chec ## Shell Completions ```bash -cm completion install # Install tab completions (prompts for shell) -cm completion install bash # Install for specific shell -cm completion install zsh -cm completion install fish -cm completion uninstall # Remove completions +tdc completion install # Install tab completions (prompts for shell) +tdc completion install bash # Install for specific shell +tdc completion install zsh +tdc completion install fish +tdc completion uninstall # Remove completions ``` ### Diagnostics ```bash -cm doctor # Run local + network diagnostics -cm doctor --offline # Skip Comms and npm network checks -cm doctor --json # JSON output with per-check results +tdc doctor # Run local + network diagnostics +tdc doctor --offline # Skip Comms and npm network checks +tdc doctor --json # JSON output with per-check results ``` ### Configuration ```bash -cm config view # Pretty-printed config, token masked, labels actual token source -cm config view --json # Raw JSON, token masked -cm config view --show-token # Include the full token -cm config set unarchive-new-threads true # Persist: always unarchive new threads so they land in your Inbox -cm config set unarchive-new-threads false # Persist: keep Comms's default (thread auto-archived for author) +tdc config view # Pretty-printed config, token masked, labels actual token source +tdc config view --json # Raw JSON, token masked +tdc config view --show-token # Include the full token +tdc config set unarchive-new-threads true # Persist: always unarchive new threads so they land in your Inbox +tdc config set unarchive-new-threads false # Persist: keep Comms's default (thread auto-archived for author) ``` -User preferences are stored under `userSettings` in the config file. Currently supported keys: `unarchive-new-threads`. The flag on `cm thread create` (`--unarchive` / `--no-unarchive`) overrides this default per-invocation. +User preferences are stored under `userSettings` in the config file. Currently supported keys: `unarchive-new-threads`. The flag on `tdc thread create` (`--unarchive` / `--no-unarchive`) overrides this default per-invocation. ### Update ```bash -cm update # Update CLI to latest version -cm update --check # Check for updates without installing, show channel -cm update --check --json # Same, JSON envelope -cm update --check --ndjson # Same, newline-delimited JSON envelope -cm update --channel # Show current update channel -cm update switch --stable # Switch to stable release channel -cm update switch --pre-release # Switch to pre-release (next) channel -cm update switch --pre-release --json # Same, JSON envelope -cm update switch --pre-release --ndjson # Same, newline-delimited JSON envelope +tdc update # Update CLI to latest version +tdc update --check # Check for updates without installing, show channel +tdc update --check --json # Same, JSON envelope +tdc update --check --ndjson # Same, newline-delimited JSON envelope +tdc update --channel # Show current update channel +tdc update switch --stable # Switch to stable release channel +tdc update switch --pre-release # Switch to pre-release (next) channel +tdc update switch --pre-release --json # Same, JSON envelope +tdc update switch --pre-release --ndjson # Same, newline-delimited JSON envelope ``` ### Changelog ```bash -cm changelog # Show last 5 versions -cm changelog -n 3 # Show last 3 versions -cm changelog --count 10 # Show last 10 versions +tdc changelog # Show last 5 versions +tdc changelog -n 3 # Show last 3 versions +tdc changelog --count 10 # Show last 10 versions ``` ## Global Options @@ -323,7 +323,7 @@ cm changelog --count 10 # Show last 10 versions --progress-jsonl # Machine-readable progress events (JSONL to stderr) --progress-jsonl= # Same, but write events to instead of stderr --progress-jsonl # Same as above (space-separated form also accepted) ---accessible # Add text labels to color-coded output (also: TW_ACCESSIBLE=1) +--accessible # Add text labels to color-coded output (also: TDC_ACCESSIBLE=1) --non-interactive # Disable interactive prompts (auto-detected when stdin is not a TTY) --interactive # Force interactive mode even when stdin is not a TTY ``` @@ -361,9 +361,9 @@ Commands accept flexible references: Commands that accept content (`thread create`, `thread reply`, `comment update`, `conversation reply`, `msg update`) auto-detect piped stdin: ```bash -cat notes.md | cm thread reply -cm thread create "Title" < body.md -echo "Quick reply" | cm conversation reply +cat notes.md | tdc thread reply +tdc thread create "Title" < body.md +echo "Quick reply" | tdc conversation reply ``` If no content argument is provided and no stdin is piped, the CLI opens `$EDITOR` for interactive input. In non-TTY environments (e.g. when called by an agent or in a pipeline), the editor is automatically skipped and the command fails fast with an actionable error message. Use `--non-interactive` to force this behavior even in a TTY, or `--interactive` to override auto-detection. @@ -372,31 +372,31 @@ 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 -cm view https://comms.todoist.com/a/1585/ch/100/t/200 # View thread -cm view https://comms.todoist.com/a/1585/ch/100/t/200/c/300 # View comment -cm view https://comms.todoist.com/a/1585/msg/400 # View conversation -cm view https://comms.todoist.com/a/1585/msg/400/m/500 --json # View message as JSON +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 +tdc view https://comms.todoist.com/a/1585/msg/400/m/500 --json # View message as JSON ``` **Check inbox and respond:** ```bash -cm inbox --unread --json -cm thread view --unread -cm thread reply "Thanks, I'll look into this." -cm thread done +tdc inbox --unread --json +tdc thread view --unread +tdc thread reply "Thanks, I'll look into this." +tdc thread done ``` **Search and review:** ```bash -cm mentions --since 2026-04-01 --all --json -cm search "deployment" --type threads --json -cm thread view +tdc mentions --since 2026-04-01 --all --json +tdc search "deployment" --type threads --json +tdc thread view ``` **Check DMs:** ```bash -cm conversation unread --json -cm conversation view -cm conversation with "Alice Example" -cm conversation reply "Got it, thanks!" +tdc conversation unread --json +tdc conversation view +tdc conversation with "Alice Example" +tdc conversation reply "Got it, thanks!" ``` diff --git a/src/commands/account/account.test.ts b/src/commands/account/account.test.ts index 51f5d26..02a32fe 100644 --- a/src/commands/account/account.test.ts +++ b/src/commands/account/account.test.ts @@ -68,7 +68,7 @@ describe('account command', () => { it('renders all stored accounts with the default marker', async () => { seedStore([ACCOUNT_ALAN, 'default'], ACCOUNT_ELLIE) - await createProgram().parseAsync(['node', 'cm', 'account', 'list']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'list']) const output = stdout() expect(output).toContain('Stored accounts (2)') @@ -79,10 +79,10 @@ describe('account command', () => { expect(output).toContain('Default: id:1 Alan Grant') }) - it('runs by default when no subcommand is given (cm account)', async () => { + it('runs by default when no subcommand is given (tdc account)', async () => { seedStore([ACCOUNT_ALAN, 'default']) - await createProgram().parseAsync(['node', 'cm', 'account']) + await createProgram().parseAsync(['node', 'tdc', 'account']) expect(stdout()).toContain('Stored accounts (1)') }) @@ -90,17 +90,17 @@ describe('account command', () => { it('reports the empty state when no accounts are stored', async () => { seedStore() - await createProgram().parseAsync(['node', 'cm', 'account', 'list']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'list']) expect(consoleSpy).toHaveBeenCalledWith( - 'No stored accounts. Run `cm auth login` to add one.', + 'No stored accounts. Run `tdc auth login` to add one.', ) }) it('emits a JSON envelope with id, label, isDefault', async () => { seedStore([ACCOUNT_ALAN, 'default'], ACCOUNT_ELLIE) - await createProgram().parseAsync(['node', 'cm', 'account', 'list', '--json']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'list', '--json']) expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual([ { id: '1', label: 'Alan Grant', isDefault: true }, @@ -114,7 +114,7 @@ describe('account command', () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.active.mockResolvedValue({ token: 'tk_abc', account: ACCOUNT_ALAN }) - await createProgram().parseAsync(['node', 'cm', 'account', 'current']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'current']) const output = stdout() expect(output).toContain('Active account: id:1 Alan Grant') @@ -127,7 +127,7 @@ describe('account command', () => { async (flag) => { vi.stubEnv(TOKEN_ENV_VAR, 'tk_env_supplied') - await createProgram().parseAsync(['node', 'cm', 'account', 'current', flag]) + await createProgram().parseAsync(['node', 'tdc', 'account', 'current', flag]) expect(consoleSpy).toHaveBeenCalledTimes(1) expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ source: 'env' }) @@ -140,7 +140,7 @@ describe('account command', () => { storeMocks.active.mockResolvedValue(null) await expect( - createProgram().parseAsync(['node', 'cm', 'account', 'current']), + createProgram().parseAsync(['node', 'tdc', 'account', 'current']), ).rejects.toHaveProperty('code', 'NO_TOKEN') }) @@ -148,7 +148,7 @@ describe('account command', () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.active.mockResolvedValue({ token: 'tk_abc', account: ACCOUNT_ALAN }) - await createProgram().parseAsync(['node', 'cm', 'account', 'current', '--json']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'current', '--json']) expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ id: '1', @@ -159,7 +159,7 @@ describe('account command', () => { }) }) - // `cm auth token` persists `{ id: '', label: '' }` since manual + // `tdc auth token` persists `{ id: '', label: '' }` since manual // token entry has no identity. `account current` must render that // shape as a distinct "token-only" source, not as a regular account // with blank fields. @@ -172,10 +172,10 @@ describe('account command', () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.active.mockResolvedValue(EMPTY_ID_SNAPSHOT) - await createProgram().parseAsync(['node', 'cm', 'account', 'current']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'current']) const output = stdout() - expect(output).toContain('saved via `cm auth token`') + expect(output).toContain('saved via `tdc auth token`') expect(output).not.toMatch(/Active account: id: {2}/) }) @@ -183,7 +183,7 @@ describe('account command', () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.active.mockResolvedValue(EMPTY_ID_SNAPSHOT) - await createProgram().parseAsync(['node', 'cm', 'account', 'current', '--json']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'current', '--json']) expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ source: 'token-only', @@ -195,7 +195,7 @@ describe('account command', () => { it('sets the default account by canonical id when the ref matches', async () => { seedStore(ACCOUNT_ALAN, [ACCOUNT_ELLIE, 'default']) - await createProgram().parseAsync(['node', 'cm', 'account', 'use', '1']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'use', '1']) expect(storeMocks.setDefault).toHaveBeenCalledTimes(1) expect(storeMocks.setDefault).toHaveBeenCalledWith('1') @@ -208,7 +208,7 @@ describe('account command', () => { seedStore([ACCOUNT_ALAN, 'default']) await expect( - createProgram().parseAsync(['node', 'cm', 'account', 'use', '999']), + createProgram().parseAsync(['node', 'tdc', 'account', 'use', '999']), ).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND') expect(storeMocks.setDefault).not.toHaveBeenCalled() @@ -217,7 +217,7 @@ describe('account command', () => { it('matches refs by display name and resolves to the canonical id', async () => { seedStore(ACCOUNT_ALAN, [ACCOUNT_ELLIE, 'default']) - await createProgram().parseAsync(['node', 'cm', 'account', 'use', 'alan grant']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'use', 'alan grant']) expect(storeMocks.setDefault).toHaveBeenCalledTimes(1) const output = stdout() @@ -230,7 +230,7 @@ describe('account command', () => { it('clears the account by canonical id and prints the removed label', async () => { seedStore([ACCOUNT_ALAN, 'default'], ACCOUNT_ELLIE) - await createProgram().parseAsync(['node', 'cm', 'account', 'remove', 'ellie sattler']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'remove', 'ellie sattler']) expect(storeMocks.clear).toHaveBeenCalledTimes(1) expect(storeMocks.clear).toHaveBeenCalledWith('2') @@ -243,7 +243,7 @@ describe('account command', () => { seedStore([ACCOUNT_ALAN, 'default']) await expect( - createProgram().parseAsync(['node', 'cm', 'account', 'remove', '999']), + createProgram().parseAsync(['node', 'tdc', 'account', 'remove', '999']), ).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND') expect(storeMocks.clear).not.toHaveBeenCalled() @@ -256,7 +256,7 @@ describe('account command', () => { warning: 'system credential manager unavailable; local auth state cleared', }) - await createProgram().parseAsync(['node', 'cm', 'account', 'remove', '1']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'remove', '1']) expect(errorSpy).toHaveBeenCalledWith( 'Warning:', @@ -267,7 +267,7 @@ describe('account command', () => { it('emits a JSON envelope and suppresses the plain confirmation', async () => { seedStore([ACCOUNT_ALAN, 'default']) - await createProgram().parseAsync(['node', 'cm', 'account', 'remove', '1', '--json']) + await createProgram().parseAsync(['node', 'tdc', 'account', 'remove', '1', '--json']) expect(consoleSpy).toHaveBeenCalledTimes(1) expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ diff --git a/src/commands/account/current.ts b/src/commands/account/current.ts index 90b2e14..3b7a523 100644 --- a/src/commands/account/current.ts +++ b/src/commands/account/current.ts @@ -16,18 +16,18 @@ export async function currentAccount(options: ViewOptions, store: CommsTokenStor const snapshot = await store.active() if (!snapshot) { throw new CliError('NO_TOKEN', 'No stored account is currently active.', [ - 'Run: cm auth login', + 'Run: tdc auth login', ]) } const { account } = snapshot - // `cm auth token` persists `{ id: '', label: '' }` because manual token + // `tdc auth token` persists `{ id: '', label: '' }` because manual token // entry has no identity. Render that case explicitly rather than printing // blank fields. if (!account.id || !account.label) { emitView(options, { source: 'token-only' }, () => [ - 'Active token saved via `cm auth token` (no associated identity).', - chalk.dim('Run `cm auth login` to attach an account to the token.'), + 'Active token saved via `tdc auth token` (no associated identity).', + chalk.dim('Run `tdc auth login` to attach an account to the token.'), ]) return } diff --git a/src/commands/account/index.ts b/src/commands/account/index.ts index a47449e..6191c05 100644 --- a/src/commands/account/index.ts +++ b/src/commands/account/index.ts @@ -42,8 +42,8 @@ export function registerAccountCommand(program: Command): void { 'after', ` Examples: - cm account # list stored accounts (default subcommand) - cm account use "Alan Grant" # pin Alan as the default account (id, id:N, or name) - cm account remove id:42 # forget id:42 (clears keyring + config entry)`, + tdc account # list stored accounts (default subcommand) + tdc account use "Alan Grant" # pin Alan as the default account (id, id:N, or name) + tdc account remove id:42 # forget id:42 (clears keyring + config entry)`, ) } diff --git a/src/commands/account/list.ts b/src/commands/account/list.ts index 1f70016..72048e8 100644 --- a/src/commands/account/list.ts +++ b/src/commands/account/list.ts @@ -15,7 +15,7 @@ export async function listAccounts(options: ViewOptions, store: CommsTokenStore) if (options.ndjson) return console.log(formatNdjson(rows)) if (rows.length === 0) { - console.log('No stored accounts. Run `cm auth login` to add one.') + console.log('No stored accounts. Run `tdc auth login` to add one.') return } diff --git a/src/commands/auth/auth.test.ts b/src/commands/auth/auth.test.ts index 87ba59b..34ae9e4 100644 --- a/src/commands/auth/auth.test.ts +++ b/src/commands/auth/auth.test.ts @@ -135,27 +135,41 @@ describe('auth command', () => { const STORED_RECORDS = [{ account: STORED_ACCOUNT, isDefault: true }] describe('token subcommand', () => { - beforeEach(() => { - storeMocks.set.mockReset().mockResolvedValue(undefined) - storeMocks.getLastStorageResult.mockReset().mockReturnValue({ storage: 'secure-store' }) - }) + let originalIsTTY: boolean | undefined + let writeSpy: ReturnType - it('prompts interactively, saves the trimmed token via store.set with an empty-id account, and confirms', async () => { - const originalIsTTY = process.stdin.isTTY - Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) + function mockPromptAnswer(answer: string): { question: ReturnType } { const mockRl = { - question: vi.fn((_prompt: string, cb: (answer: string) => void) => { - cb(' some_token_123456789 ') - }), + question: vi.fn((_prompt: string, cb: (answer: string) => void) => cb(answer)), close: vi.fn(), _writeToOutput: vi.fn(), } mockCreateInterface.mockReturnValue(mockRl as unknown as Interface) - const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + return { question: mockRl.question } + } + + beforeEach(() => { + storeMocks.set.mockReset().mockResolvedValue(undefined) + storeMocks.getLastStorageResult.mockReset().mockReturnValue({ storage: 'secure-store' }) + originalIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + writeSpy.mockRestore() + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }) + }) + + it('prompts interactively, saves the trimmed token via store.set with an empty-id account, and confirms', async () => { + const { question } = mockPromptAnswer(' some_token_123456789 ') - await createProgram().parseAsync(['node', 'cm', 'auth', 'token']) + await createProgram().parseAsync(['node', 'tdc', 'auth', 'token']) - expect(mockRl.question).toHaveBeenCalled() + expect(question).toHaveBeenCalled() expect(storeMocks.set).toHaveBeenCalledWith( { id: '', label: '', authMode: 'unknown', authScope: '' }, 'some_token_123456789', @@ -164,36 +178,15 @@ describe('auth command', () => { expect(consoleSpy).toHaveBeenCalledWith( 'Token stored securely in the system credential manager', ) - writeSpy.mockRestore() - Object.defineProperty(process.stdin, 'isTTY', { - value: originalIsTTY, - configurable: true, - }) }) it('lets store.set errors propagate unchanged', async () => { storeMocks.set.mockRejectedValue(new Error('Permission denied')) - const originalIsTTY = process.stdin.isTTY - Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) - const mockRl = { - question: vi.fn((_prompt: string, cb: (answer: string) => void) => { - cb('some_token_123456789') - }), - close: vi.fn(), - _writeToOutput: vi.fn(), - } - mockCreateInterface.mockReturnValue(mockRl as unknown as Interface) - const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + mockPromptAnswer('some_token_123456789') await expect( - createProgram().parseAsync(['node', 'cm', 'auth', 'token']), + createProgram().parseAsync(['node', 'tdc', 'auth', 'token']), ).rejects.toThrow('Permission denied') - - writeSpy.mockRestore() - Object.defineProperty(process.stdin, 'isTTY', { - value: originalIsTTY, - configurable: true, - }) }) it('surfaces the keyring-fallback warning from getLastStorageResult on stderr', async () => { @@ -202,60 +195,48 @@ describe('auth command', () => { warning: 'system credential manager unavailable; token saved as plaintext in /home/user/.config/comms-cli/config.json', }) - const originalIsTTY = process.stdin.isTTY - Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) - const mockRl = { - question: vi.fn((_prompt: string, cb: (answer: string) => void) => { - cb('some_token_123456789') - }), - close: vi.fn(), - _writeToOutput: vi.fn(), - } - mockCreateInterface.mockReturnValue(mockRl as unknown as Interface) - const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + mockPromptAnswer('some_token_123456789') - await createProgram().parseAsync(['node', 'cm', 'auth', 'token']) + await createProgram().parseAsync(['node', 'tdc', 'auth', 'token']) expect(errorSpy).toHaveBeenCalledWith( 'Warning:', 'system credential manager unavailable; token saved as plaintext in /home/user/.config/comms-cli/config.json', ) - - writeSpy.mockRestore() - Object.defineProperty(process.stdin, 'isTTY', { - value: originalIsTTY, - configurable: true, - }) }) it('throws NO_TOKEN without calling store.set when the input is empty (interactive + non-interactive)', async () => { - // interactive empty - const originalIsTTY = process.stdin.isTTY - Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }) - const mockRl = { - question: vi.fn((_prompt: string, cb: (answer: string) => void) => cb('')), - close: vi.fn(), - _writeToOutput: vi.fn(), - } - mockCreateInterface.mockReturnValue(mockRl as unknown as Interface) - const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + mockPromptAnswer('') await expect( - createProgram().parseAsync(['node', 'cm', 'auth', 'token']), + createProgram().parseAsync(['node', 'tdc', 'auth', 'token']), ).rejects.toHaveProperty('code', 'NO_TOKEN') // non-interactive (no TTY, no arg) Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }) await expect( - createProgram().parseAsync(['node', 'cm', 'auth', 'token']), + createProgram().parseAsync(['node', 'tdc', 'auth', 'token']), ).rejects.toHaveProperty('code', 'NO_TOKEN') expect(storeMocks.set).not.toHaveBeenCalled() - writeSpy.mockRestore() - Object.defineProperty(process.stdin, 'isTTY', { - value: originalIsTTY, - configurable: true, - }) + }) + + // Regression: positional secrets violate the Doist Secrets Management + // Standard (visible in `ps` / shell history). The command must reject + // unknown positionals rather than treat them as the token. + it('rejects a token passed as a positional argument and never calls store.set', async () => { + mockPromptAnswer('') // ensure no fall-through to the interactive prompt + + await expect( + createProgram().parseAsync([ + 'node', + 'tdc', + 'auth', + 'token', + 'positional_secret_should_be_rejected', + ]), + ).rejects.toThrow() + expect(storeMocks.set).not.toHaveBeenCalled() }) }) @@ -284,7 +265,7 @@ describe('auth command', () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.active.mockResolvedValue(STORED_SNAPSHOT) - await createProgram().parseAsync(['node', 'cm', 'auth', 'token', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'auth', 'token', 'view']) expect(stdoutPayload()).toBe('tk_stored_1234567890') expect(consoleSpy).not.toHaveBeenCalled() @@ -294,7 +275,7 @@ describe('auth command', () => { vi.stubEnv(TOKEN_ENV_VAR, 'env_token_supplied_externally') await expect( - createProgram().parseAsync(['node', 'cm', 'auth', 'token', 'view']), + createProgram().parseAsync(['node', 'tdc', 'auth', 'token', 'view']), ).rejects.toHaveProperty('code', 'TOKEN_FROM_ENV') expect(storeMocks.active).not.toHaveBeenCalled() @@ -306,7 +287,7 @@ describe('auth command', () => { storeMocks.active.mockResolvedValue(null) await expect( - createProgram().parseAsync(['node', 'cm', 'auth', 'token', 'view']), + createProgram().parseAsync(['node', 'tdc', 'auth', 'token', 'view']), ).rejects.toHaveProperty('code', 'NOT_AUTHENTICATED') expect(stdoutPayload()).toBe('') @@ -316,7 +297,15 @@ describe('auth command', () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.active.mockResolvedValue(STORED_SNAPSHOT) - await createProgram().parseAsync(['node', 'cm', 'auth', 'token', 'view', '--user', '1']) + await createProgram().parseAsync([ + 'node', + 'tdc', + 'auth', + 'token', + 'view', + '--user', + '1', + ]) expect(storeMocks.active).toHaveBeenCalledWith('1') expect(stdoutPayload()).toBe('tk_stored_1234567890') @@ -329,7 +318,7 @@ describe('auth command', () => { await expect( createProgram().parseAsync([ 'node', - 'cm', + 'tdc', 'auth', 'token', 'view', @@ -361,14 +350,14 @@ describe('auth command', () => { vi.unstubAllEnvs() }) - it('threads `cm --user auth token view` into store.active', async () => { + it('threads `tdc --user auth token view` into store.active', async () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.list.mockResolvedValue(STORED_RECORDS) storeMocks.active.mockResolvedValue(STORED_SNAPSHOT) - process.argv = ['node', 'cm', '--user', '1', 'auth', 'token', 'view'] + process.argv = ['node', 'tdc', '--user', '1', 'auth', 'token', 'view'] resetGlobalArgs() - await createProgram().parseAsync(['node', 'cm', 'auth', 'token', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'auth', 'token', 'view']) expect(storeMocks.active).toHaveBeenCalledWith('1') expect(writeSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('')).toBe( @@ -376,7 +365,7 @@ describe('auth command', () => { ) }) - it('threads `cm --user auth status` into the snapshot used by fetchLive', async () => { + it('threads `tdc --user auth status` into the snapshot used by fetchLive', async () => { vi.stubEnv(TOKEN_ENV_VAR, '') storeMocks.list.mockResolvedValue(STORED_RECORDS) storeMocks.active.mockResolvedValue(STORED_SNAPSHOT) @@ -389,17 +378,17 @@ describe('auth command', () => { authScope: 'user:read', source: 'config', }) - process.argv = ['node', 'cm', '--user', '1', 'auth', 'status'] + process.argv = ['node', 'tdc', '--user', '1', 'auth', 'status'] resetGlobalArgs() - await createProgram().parseAsync(['node', 'cm', 'auth', 'status']) + await createProgram().parseAsync(['node', 'tdc', 'auth', 'status']) expect(storeMocks.active).toHaveBeenCalledWith('1') expect(mockCreateWrappedCommsClient).toHaveBeenCalledWith('tk_stored_1234567890') expect(consoleSpy).toHaveBeenCalledWith('✓ Authenticated') }) - it('blocks `cm --user auth logout` with ACCOUNT_NOT_FOUND before touching storage', async () => { + it('blocks `tdc --user auth logout` with ACCOUNT_NOT_FOUND before touching storage', async () => { // `withUserRefAware` validates the global ref against `store.list()` // before substituting it into the store call, so a non-matching ref // surfaces as a typed miss instead of cli-core's silent clear no-op. @@ -413,12 +402,12 @@ describe('auth command', () => { }, }, ]) - process.argv = ['node', 'cm', '--user', '999', 'auth', 'logout'] + process.argv = ['node', 'tdc', '--user', '999', 'auth', 'logout'] resetGlobalArgs() const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'auth', 'logout']), + program.parseAsync(['node', 'tdc', 'auth', 'logout']), ).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND') expect(storeMocks.clear).not.toHaveBeenCalled() @@ -473,7 +462,7 @@ describe('auth command', () => { }) it('renders text status from the snapshot', async () => { - await programWithSnapshot().parseAsync(['node', 'cm', 'auth', 'status']) + await programWithSnapshot().parseAsync(['node', 'tdc', 'auth', 'status']) expect(mockCreateWrappedCommsClient).toHaveBeenCalledWith('snapshot_token') expect(consoleSpy).toHaveBeenCalledWith('✓ Authenticated') @@ -483,7 +472,7 @@ describe('auth command', () => { }) it('emits the JSON envelope from the snapshot path', async () => { - await programWithSnapshot().parseAsync(['node', 'cm', 'auth', 'status', '--json']) + await programWithSnapshot().parseAsync(['node', 'tdc', 'auth', 'status', '--json']) const printed = consoleSpy.mock.calls[0][0] as string expect(JSON.parse(printed)).toEqual({ @@ -497,7 +486,7 @@ describe('auth command', () => { }) it('emits a single newline-free NDJSON line from the snapshot path', async () => { - await programWithSnapshot().parseAsync(['node', 'cm', 'auth', 'status', '--ndjson']) + await programWithSnapshot().parseAsync(['node', 'tdc', 'auth', 'status', '--ndjson']) // NDJSON must be one JSON value per line — assert one console.log // call whose payload contains no embedded newline (would slip @@ -530,7 +519,7 @@ describe('auth command', () => { } as any) await expect( - programWithSnapshot().parseAsync(['node', 'cm', 'auth', 'status']), + programWithSnapshot().parseAsync(['node', 'tdc', 'auth', 'status']), ).rejects.toHaveProperty('code', 'NO_TOKEN') }) @@ -557,7 +546,7 @@ describe('auth command', () => { attachCommsStatusCommand(auth, emptyStore) await expect( - program.parseAsync(['node', 'cm', 'auth', 'status']), + program.parseAsync(['node', 'tdc', 'auth', 'status']), ).rejects.toHaveProperty('code', 'NO_TOKEN') }) }) @@ -607,7 +596,7 @@ describe('auth command', () => { }) it('clears the API token', async () => { - await createProgram().parseAsync(['node', 'cm', 'auth', 'logout']) + await createProgram().parseAsync(['node', 'tdc', 'auth', 'logout']) expect(storeMocks.clear).toHaveBeenCalled() expect(consoleSpy).toHaveBeenCalledWith('✓ Logged out') @@ -619,7 +608,7 @@ describe('auth command', () => { it('surfaces keyring-fallback warning to stderr in plain mode', async () => { storeMocks.getLastClearResult.mockReturnValue(WARNING_RESULT) - await createProgram().parseAsync(['node', 'cm', 'auth', 'logout']) + await createProgram().parseAsync(['node', 'tdc', 'auth', 'logout']) expect(consoleSpy).toHaveBeenCalledWith('✓ Logged out') expect(errorSpy).toHaveBeenCalledWith('Warning:', WARNING_RESULT.warning) @@ -628,7 +617,7 @@ describe('auth command', () => { it('routes warning to stderr and emits JSON envelope on stdout in --json mode', async () => { storeMocks.getLastClearResult.mockReturnValue(WARNING_RESULT) - await createProgram().parseAsync(['node', 'cm', 'auth', 'logout', '--json']) + await createProgram().parseAsync(['node', 'tdc', 'auth', 'logout', '--json']) const stdoutLines = consoleSpy.mock.calls.map((c: unknown[]) => String(c[0])) expect(stdoutLines).toHaveLength(1) @@ -641,7 +630,7 @@ describe('auth command', () => { it('routes warning to stderr and keeps stdout silent in --ndjson mode', async () => { storeMocks.getLastClearResult.mockReturnValue(WARNING_RESULT) - await createProgram().parseAsync(['node', 'cm', 'auth', 'logout', '--ndjson']) + await createProgram().parseAsync(['node', 'tdc', 'auth', 'logout', '--ndjson']) expect(consoleSpy).not.toHaveBeenCalled() expect(errorSpy).toHaveBeenCalledWith('Warning:', WARNING_RESULT.warning) diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index e0571b4..e971d99 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -19,7 +19,7 @@ export function registerAuthCommand(program: Command): void { attachCommsLogoutCommand(auth, refAware) attachCommsStatusCommand(auth, refAware) - // `token` is a hybrid: bare `cm auth token` prompts interactively to save + // `token` is a hybrid: bare `tdc auth token` prompts interactively to save // a token, and the `view` subcommand prints it. Tokens are never accepted // as positional/CLI arguments — that would leak them via process lists // and shell history (Doist Secrets Management Standard). diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 2cc0bf7..c63d67a 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -4,7 +4,7 @@ import type { CommsAccount, CommsTokenStore } from '../../lib/auth-provider.js' import { logTokenStorageResult } from './helpers.js' /** - * Attach `cm auth logout` via cli-core's generic `attachLogoutCommand`. The + * Attach `tdc auth logout` via cli-core's generic `attachLogoutCommand`. The * registrar emits the success line (`✓ Logged out` / `{ok:true}` / silent * ndjson); `onCleared` only surfaces the keyring-fallback warning carried by * `TokenStorageResult` — cli-core's `TokenStore.clear: void` contract can't diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 3cc028a..e9a0793 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -40,7 +40,7 @@ async function gatherStatusData(token: string): Promise { } catch (error) { if (error instanceof CommsRequestError && error.httpStatusCode === 401) { throw new CliError('NO_TOKEN', 'Not authenticated (token expired or invalid)', [ - 'Run `cm auth login` to re-authenticate', + 'Run `tdc auth login` to re-authenticate', ]) } throw error @@ -69,12 +69,12 @@ function buildStatusJson({ user, metadata }: StatusData): Record` (stripped by `src/index.ts`) into +// Bridge the global `tdc --user ` (stripped by `src/index.ts`) into // cli-core's attachers, which only see per-command `--user`. Explicit ref // passed by commander wins over the captured global ref. // @@ -10,7 +10,7 @@ import { findAccountInStore, type CommsTokenStore } from '../../lib/auth-provide // surface via `onNotAuthenticated` (status / token view). `clear()` does the // extra existence check first via `findAccountInStore`, because cli-core's // `KeyringTokenStore.clear` is a silent no-op on a non-matching ref and -// would otherwise let `cm --user auth logout` print `✓ Logged out`. +// would otherwise let `tdc --user auth logout` print `✓ Logged out`. export function withUserRefAware( store: CommsTokenStore, requestedRef: AccountRef | undefined, diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 921889f..571d5a0 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -42,9 +42,9 @@ export async function loginWithToken(): Promise { const trimmed = token.trim() if (!trimmed) { throw new CliError('NO_TOKEN', 'No token provided', [ - 'Run: cm auth token (interactive prompt)', + 'Run: tdc auth token (interactive prompt)', 'Or set COMMS_API_TOKEN environment variable', - 'Or use OAuth: cm auth login', + 'Or use OAuth: tdc auth login', ]) } // Manual token entry has no identity (no API call to resolve the user). diff --git a/src/commands/away/away.test.ts b/src/commands/away/away.test.ts index ab4b27e..16e8240 100644 --- a/src/commands/away/away.test.ts +++ b/src/commands/away/away.test.ts @@ -48,7 +48,7 @@ describe('away', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'away']) + await program.parseAsync(['node', 'tdc', 'away']) expect(logSpy).toHaveBeenCalledWith('Not away.') logSpy.mockRestore() @@ -63,7 +63,7 @@ describe('away', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'away']) + await program.parseAsync(['node', 'tdc', 'away']) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Vacation')) logSpy.mockRestore() @@ -75,7 +75,7 @@ describe('away', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'away', 'set', 'vacation', '2026-03-20']) + await program.parseAsync(['node', 'tdc', 'away', 'set', 'vacation', '2026-03-20']) expect(apiMocks.updateUser).toHaveBeenCalledWith( expect.objectContaining({ @@ -94,7 +94,7 @@ describe('away', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'away', 'set', 'vacation', @@ -115,7 +115,7 @@ describe('away', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'away', 'set', 'vacation', @@ -137,7 +137,7 @@ describe('away', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'away', 'set', 'vacation', '2026-03-20']), + program.parseAsync(['node', 'tdc', 'away', 'set', 'vacation', '2026-03-20']), ).rejects.toThrow(scopeError) }) @@ -145,7 +145,7 @@ describe('away', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'away', 'set', 'invalid', '2026-03-20']), + program.parseAsync(['node', 'tdc', 'away', 'set', 'invalid', '2026-03-20']), ).rejects.toHaveProperty('code', 'INVALID_TYPE') }) }) @@ -155,7 +155,7 @@ describe('away', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'away', 'clear']) + await program.parseAsync(['node', 'tdc', 'away', 'clear']) expect(apiMocks.updateUser).toHaveBeenCalledWith({ awayMode: '' }) expect(logSpy).toHaveBeenCalledWith('Away status cleared.') @@ -166,7 +166,7 @@ describe('away', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'away', 'clear', '--dry-run']) + await program.parseAsync(['node', 'tdc', 'away', 'clear', '--dry-run']) expect(apiMocks.updateUser).not.toHaveBeenCalled() expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Would clear away status')) diff --git a/src/commands/away/index.ts b/src/commands/away/index.ts index e2fc36e..d4a6a6d 100644 --- a/src/commands/away/index.ts +++ b/src/commands/away/index.ts @@ -14,8 +14,8 @@ export function registerAwayCommand(program: Command): void { 'after', ` Examples: - cm away - cm away --json`, + tdc away + tdc away --json`, ) .action((options: ViewOptions) => showAwayStatus(options)) @@ -30,9 +30,9 @@ Examples: 'after', ` Examples: - cm away set vacation 2025-12-31 - cm away set sickleave --from 2025-06-01 - cm away set other 2025-07-01 --dry-run`, + tdc away set vacation 2025-12-31 + tdc away set sickleave --from 2025-06-01 + tdc away set other 2025-07-01 --dry-run`, ) .action( ( @@ -51,8 +51,8 @@ Examples: 'after', ` Examples: - cm away clear - cm away clear --dry-run`, + tdc away clear + tdc away clear --dry-run`, ) .action((options: MutationOptions & ViewOptions) => clearAway(options)) } diff --git a/src/commands/changelog.test.ts b/src/commands/changelog.test.ts index 97a0302..6de526f 100644 --- a/src/commands/changelog.test.ts +++ b/src/commands/changelog.test.ts @@ -54,7 +54,7 @@ describe('changelog wrapper', () => { it('passes the comms CHANGELOG.md path through to cli-core', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - await createProgram().parseAsync(['node', 'cm', 'changelog', '-n', '1']) + await createProgram().parseAsync(['node', 'tdc', 'changelog', '-n', '1']) expect(mockReadFile).toHaveBeenCalledTimes(1) const [path] = mockReadFile.mock.calls[0] @@ -64,7 +64,7 @@ describe('changelog wrapper', () => { it('emits a footer link pointing at the comms repo and current version', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - await createProgram().parseAsync(['node', 'cm', 'changelog', '-n', '1']) + await createProgram().parseAsync(['node', 'tdc', 'changelog', '-n', '1']) const all = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n') expect(all).toContain( @@ -75,7 +75,7 @@ describe('changelog wrapper', () => { it('renders both # and ## version headings (headingLevel: flexible)', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - await createProgram().parseAsync(['node', 'cm', 'changelog', '-n', '5']) + await createProgram().parseAsync(['node', 'tdc', 'changelog', '-n', '5']) const all = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n') expect(all).toContain('9.9.0') @@ -89,7 +89,7 @@ describe('changelog wrapper', () => { it('drops deps-only versions (filterEmptyVersions: true)', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - await createProgram().parseAsync(['node', 'cm', 'changelog', '-n', '5']) + await createProgram().parseAsync(['node', 'tdc', 'changelog', '-n', '5']) const all = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n') expect(all).not.toContain('9.8.5') @@ -99,7 +99,7 @@ describe('changelog wrapper', () => { it('indents continuation lines under bullets (continuationIndent: true)', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - await createProgram().parseAsync(['node', 'cm', 'changelog', '-n', '1']) + await createProgram().parseAsync(['node', 'tdc', 'changelog', '-n', '1']) const all = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n') // Continuation line should be indented under the bullet (more diff --git a/src/commands/channel/channel.test.ts b/src/commands/channel/channel.test.ts index 87b67f1..a96b639 100644 --- a/src/commands/channel/channel.test.ts +++ b/src/commands/channel/channel.test.ts @@ -84,7 +84,7 @@ describe('channels list', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'channels', 'Doist', '--workspace', 'Other']), + program.parseAsync(['node', 'tdc', 'channels', 'Doist', '--workspace', 'Other']), ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') }) @@ -99,7 +99,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channels']) + await program.parseAsync(['node', 'tdc', 'channels']) expect(client.channels.getChannels).toHaveBeenCalledWith({ workspaceId: 1, @@ -121,7 +121,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel']) + await program.parseAsync(['node', 'tdc', 'channel']) expect(client.channels.getChannels).toHaveBeenCalledWith({ workspaceId: 1, @@ -140,7 +140,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel', 'list']) + await program.parseAsync(['node', 'tdc', 'channel', 'list']) expect(client.channels.getChannels).toHaveBeenCalledWith({ workspaceId: 1, @@ -162,7 +162,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channels']) + await program.parseAsync(['node', 'tdc', 'channels']) expect(consoleSpy).toHaveBeenCalledTimes(2) expect(client.channels.getChannels).toHaveBeenCalledWith({ @@ -191,7 +191,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channels', '--scope', 'public']) + await program.parseAsync(['node', 'tdc', 'channels', '--scope', 'public']) expect(client.channels.getChannels).toHaveBeenCalledWith({ workspaceId: 1 }) expect(client.workspaces.getPublicChannels).toHaveBeenCalledWith(1) @@ -214,7 +214,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channels', '--scope', 'discoverable', '--json']) + await program.parseAsync(['node', 'tdc', 'channels', '--scope', 'discoverable', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual([ @@ -232,7 +232,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channels', '--state', 'archived']) + await program.parseAsync(['node', 'tdc', 'channels', '--state', 'archived']) expect(client.channels.getChannels).toHaveBeenCalledWith({ workspaceId: 1, archived: true }) expect(consoleSpy).toHaveBeenCalledTimes(1) @@ -256,7 +256,7 @@ describe('channels list', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'channels', '--scope', 'public', @@ -285,7 +285,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channels', '--state', 'all', '--json']) + await program.parseAsync(['node', 'tdc', 'channels', '--state', 'all', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual([ @@ -307,7 +307,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channels', '--state', 'all', '--ndjson']) + await program.parseAsync(['node', 'tdc', 'channels', '--state', 'all', '--ndjson']) const ndjsonOutput = consoleSpy.mock.calls[0][0] .split('\n') @@ -331,7 +331,7 @@ describe('channels list', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'channels', '--scope', 'public', @@ -357,7 +357,7 @@ describe('channels list', () => { }, run: async (extraArgs) => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'channels', ...extraArgs]) + await program.parseAsync(['node', 'tdc', 'channels', ...extraArgs]) }, humanMessage: 'No active channels found.', }) @@ -374,7 +374,7 @@ describe('channels list', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channels', '--scope', 'discoverable']) + await program.parseAsync(['node', 'tdc', 'channels', '--scope', 'discoverable']) expect(consoleSpy).toHaveBeenCalledWith('No active discoverable channels found.') @@ -387,7 +387,7 @@ describe('channels list', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'channels', '--scope', 'invalid']), + program.parseAsync(['node', 'tdc', 'channels', '--scope', 'invalid']), ).rejects.toHaveProperty('code', 'INVALID_SCOPE') }) @@ -397,7 +397,7 @@ describe('channels list', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'channels', '--state', 'invalid']), + program.parseAsync(['node', 'tdc', 'channels', '--state', 'invalid']), ).rejects.toHaveProperty('code', 'INVALID_STATE') }) }) diff --git a/src/commands/channel/index.ts b/src/commands/channel/index.ts index ec5ead3..9bdf722 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -28,13 +28,13 @@ export function registerChannelCommand(program: Command): void { 'after', ` Examples: - cm channels - cm channels --state all - cm channels --scope discoverable - cm channels --scope public --state archived - cm channels --scope public --state all --json - cm channels --json - cm channels "My Workspace" --scope discoverable --json + tdc channels + tdc channels --state all + tdc channels --scope discoverable + tdc channels --scope public --state archived + tdc channels --scope public --state all --json + tdc channels --json + tdc channels "My Workspace" --scope discoverable --json Notes: Defaults to active channels that you have joined. @@ -74,12 +74,12 @@ Notes: 'after', ` Examples: - cm channel threads 12345 - cm channel threads "general" - cm channel threads id:12345 --unread - cm channel threads 12345 --archive-filter all --since 2026-01-01 - cm channel threads 12345 --limit 20 --json - cm channel threads 12345 --limit 20 --cursor + tdc channel threads 12345 + tdc channel threads "general" + tdc channel threads id:12345 --unread + tdc channel threads 12345 --archive-filter all --since 2026-01-01 + tdc channel threads 12345 --limit 20 --json + tdc channel threads 12345 --limit 20 --cursor Notes: Sorted newest-first by last activity. --limit, --cursor, --since, --until, diff --git a/src/commands/channel/threads.test.ts b/src/commands/channel/threads.test.ts index 60e5b13..d1f7f7b 100644 --- a/src/commands/channel/threads.test.ts +++ b/src/commands/channel/threads.test.ts @@ -124,7 +124,7 @@ describe('channel threads', () => { await expect( program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', 'general', @@ -139,7 +139,7 @@ describe('channel threads', () => { setupClient() const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel', 'threads', 'general', '--json']) + await program.parseAsync(['node', 'tdc', 'channel', 'threads', 'general', '--json']) expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('general', 1) }) @@ -149,7 +149,15 @@ describe('channel threads', () => { setupClient() const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel', 'threads', 'general', 'Doist', '--json']) + await program.parseAsync([ + 'node', + 'tdc', + 'channel', + 'threads', + 'general', + 'Doist', + '--json', + ]) expect(refsMocks.resolveWorkspaceRef).toHaveBeenCalledWith('Doist') expect(refsMocks.resolveChannelRef).toHaveBeenCalledWith('general', 42) @@ -159,7 +167,7 @@ describe('channel threads', () => { const { mockGetThreads } = setupClient() const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'tdc', 'channel', 'threads', '12345', '--json']) expect(mockGetThreads).toHaveBeenCalledWith( { workspaceId: 1, channelId: 100, archived: false }, @@ -173,7 +181,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', '12345', @@ -194,7 +202,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', '12345', @@ -217,7 +225,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'tdc', 'channel', 'threads', '12345', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results.find((t: { id: number }) => t.id === 1).isUnread).toBe(false) @@ -237,7 +245,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', '12345', @@ -265,7 +273,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', '12345', @@ -293,7 +301,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', '12345', @@ -319,7 +327,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'tdc', 'channel', 'threads', '12345', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results.map((t: { id: number }) => t.id)).toEqual([2, 3, 1]) @@ -342,7 +350,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', '12345', @@ -373,7 +381,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', '12345', @@ -398,7 +406,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'tdc', 'channel', 'threads', '12345', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.nextCursor).toBeNull() @@ -411,7 +419,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'tdc', 'channel', 'threads', '12345', '--json']) expect(vi.mocked(assertChannelIsPublic)).toHaveBeenCalledWith(100, 1) @@ -426,7 +434,7 @@ describe('channel threads', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']), + program.parseAsync(['node', 'tdc', 'channel', 'threads', '12345', '--json']), ).rejects.toThrow('This thread belongs to a private channel.') }) @@ -437,7 +445,7 @@ describe('channel threads', () => { await expect( program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', '12345', @@ -455,7 +463,7 @@ describe('channel threads', () => { await expect( program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', '12345', @@ -473,7 +481,7 @@ describe('channel threads', () => { await expect( program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', '12345', @@ -490,7 +498,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel', 'threads', 'general']) + await program.parseAsync(['node', 'tdc', 'channel', 'threads', 'general']) expect(consoleSpy).toHaveBeenCalledWith('No threads in #general.') @@ -505,7 +513,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']) + await program.parseAsync(['node', 'tdc', 'channel', 'threads', '12345', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results[0]).toMatchObject({ @@ -530,7 +538,7 @@ describe('channel threads', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'channel', 'threads', '12345', @@ -557,7 +565,7 @@ describe('channel threads', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']), + program.parseAsync(['node', 'tdc', 'channel', 'threads', '12345', '--json']), ).resolves.not.toThrow() const output = JSON.parse(consoleSpy.mock.calls[0][0]) @@ -582,7 +590,7 @@ describe('channel threads', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json']), + program.parseAsync(['node', 'tdc', 'channel', 'threads', '12345', '--json']), ).rejects.toThrow('Failed to fetch threads: Channel access denied') }) @@ -593,7 +601,7 @@ describe('channel threads', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'channel', 'threads', '12345', '--json', '--full']) + await program.parseAsync(['node', 'tdc', 'channel', 'threads', '12345', '--json', '--full']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.results[0]).toHaveProperty('pinned', true) diff --git a/src/commands/comment/comment.test.ts b/src/commands/comment/comment.test.ts index e738be5..e871635 100644 --- a/src/commands/comment/comment.test.ts +++ b/src/commands/comment/comment.test.ts @@ -91,11 +91,11 @@ describe('comment implicit view', () => { apiMocks.getCommsClient.mockRejectedValue(new Error('MOCK_API_REACHED')) }) - it('cm comment routes to view (not unknown command)', async () => { + it('tdc comment routes to view (not unknown command)', async () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await expect(program.parseAsync(['node', 'cm', 'comment', '300'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'tdc', 'comment', '300'])).rejects.toThrow( 'MOCK_API_REACHED', ) @@ -114,7 +114,7 @@ describe('comment view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'comment', 'view', '300']) + await program.parseAsync(['node', 'tdc', 'comment', 'view', '300']) expect(client.comments.getComment).toHaveBeenCalledWith(300) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Comment 300')) @@ -127,7 +127,7 @@ describe('comment view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'comment', 'view', '300', '--json']) + await program.parseAsync(['node', 'tdc', 'comment', 'view', '300', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(300) @@ -141,7 +141,7 @@ describe('comment view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'comment', 'view', '300', '--ndjson']) + await program.parseAsync(['node', 'tdc', 'comment', 'view', '300', '--ndjson']) const line = consoleSpy.mock.calls[0][0] expect(line).not.toContain('\n') @@ -157,7 +157,7 @@ describe('comment view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'comment', 'view', '300', '--json', '--full']) + await program.parseAsync(['node', 'tdc', 'comment', 'view', '300', '--json', '--full']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(300) @@ -177,7 +177,7 @@ describe('comment update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'comment', 'update', '300', 'Updated content']) + await program.parseAsync(['node', 'tdc', 'comment', 'update', '300', 'Updated content']) expect(client.comments.updateComment).toHaveBeenCalledWith({ id: 300, @@ -193,7 +193,7 @@ describe('comment update', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'comment', 'update', '300', @@ -213,7 +213,7 @@ describe('comment update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'comment', 'update', '300', 'Updated', '--json']) + await program.parseAsync(['node', 'tdc', 'comment', 'update', '300', 'Updated', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(300) @@ -228,7 +228,7 @@ describe('comment update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'comment', 'update', '300']) + await program.parseAsync(['node', 'tdc', 'comment', 'update', '300']) expect(client.comments.updateComment).toHaveBeenCalledWith({ id: 300, @@ -242,7 +242,7 @@ describe('comment update', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await expect( - program.parseAsync(['node', 'cm', 'comment', 'update', '300']), + program.parseAsync(['node', 'tdc', 'comment', 'update', '300']), ).rejects.toHaveProperty('code', 'MISSING_CONTENT') consoleSpy.mockRestore() @@ -260,7 +260,7 @@ describe('comment delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'comment', 'delete', '300']) + await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300']) expect(client.comments.deleteComment).toHaveBeenCalledWith(300) expect(consoleSpy).toHaveBeenCalledWith('Comment 300 deleted.') @@ -273,7 +273,7 @@ describe('comment delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'comment', 'delete', '300', '--dry-run']) + await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would delete comment')) expect(consoleSpy).toHaveBeenCalledWith(' Comment: 300') @@ -288,7 +288,7 @@ describe('comment delete', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'comment', 'delete', '300', '--dry-run']), + program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--dry-run']), ).rejects.toHaveProperty('code', 'NOT_CREATOR') expect(client.comments.deleteComment).not.toHaveBeenCalled() }) @@ -302,7 +302,7 @@ describe('comment delete', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'comment', 'delete', '300', '--dry-run']), + program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--dry-run']), ).rejects.toThrow('channel is private') expect(client.comments.deleteComment).not.toHaveBeenCalled() }) @@ -313,7 +313,7 @@ describe('comment delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'comment', 'delete', '300', '--json']) + await program.parseAsync(['node', 'tdc', 'comment', 'delete', '300', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual({ id: 300, deleted: true }) diff --git a/src/commands/comment/index.ts b/src/commands/comment/index.ts index 011b06e..b4a053a 100644 --- a/src/commands/comment/index.ts +++ b/src/commands/comment/index.ts @@ -19,8 +19,8 @@ export function registerCommentCommand(program: Command): void { 'after', ` Examples: - cm comment 12345 - cm comment view 12345 --json`, + tdc comment 12345 + tdc comment view 12345 --json`, ) .action((ref, options) => { if (!ref) { @@ -40,9 +40,9 @@ Examples: 'after', ` Examples: - cm comment update 12345 "Updated text" - echo "New content" | cm comment update 12345 - cm comment update 12345 "Fixed" --json`, + tdc comment update 12345 "Updated text" + echo "New content" | tdc comment update 12345 + tdc comment update 12345 "Fixed" --json`, ) .action(updateComment) @@ -55,8 +55,8 @@ Examples: 'after', ` Examples: - cm comment delete 12345 - cm comment delete 12345 --dry-run`, + tdc comment delete 12345 + tdc comment delete 12345 --dry-run`, ) .action(deleteComment) } diff --git a/src/commands/completion/helpers.ts b/src/commands/completion/helpers.ts index 8c01984..ddfca38 100644 --- a/src/commands/completion/helpers.ts +++ b/src/commands/completion/helpers.ts @@ -13,7 +13,7 @@ export const COMPLETION_EXTENSIONS: Record = { } /** - * Find which shells have cm completions installed by checking for the + * Find which shells have tdc completions installed by checking for the * completion script files that tabtab creates. * * FIXME: Workaround for https://github.com/pnpm/tabtab/issues/34 — @@ -24,7 +24,7 @@ export const COMPLETION_EXTENSIONS: Record = { export function installedShells(): SupportedShell[] { return Object.entries(COMPLETION_EXTENSIONS) .filter(([shell, ext]) => - existsSync(join(homedir(), '.config', 'tabtab', shell, `cm.${ext}`)), + existsSync(join(homedir(), '.config', 'tabtab', shell, `tdc.${ext}`)), ) .map(([shell]) => shell as SupportedShell) } @@ -32,7 +32,7 @@ export function installedShells(): SupportedShell[] { export function resolveCompleterCommand(): string { const invokedScript = process.argv[1] if (!invokedScript) { - return 'cm' + return 'tdc' } const resolvedScript = resolve(invokedScript) @@ -40,5 +40,5 @@ export function resolveCompleterCommand(): string { return resolvedScript } - return 'cm' + return 'tdc' } diff --git a/src/commands/completion/install.ts b/src/commands/completion/install.ts index f3d828b..c4e952a 100644 --- a/src/commands/completion/install.ts +++ b/src/commands/completion/install.ts @@ -14,9 +14,9 @@ export async function installCompletion(shell?: string): Promise { } await tabtab.install({ - name: 'cm', + name: 'tdc', // Use the executable path used to install completions so shell - // completion doesn't accidentally call an older `cm` on PATH. + // completion doesn't accidentally call an older `tdc` on PATH. completer, shell: shell as SupportedShell, }) diff --git a/src/commands/completion/uninstall.ts b/src/commands/completion/uninstall.ts index 4fe8aee..19c857c 100644 --- a/src/commands/completion/uninstall.ts +++ b/src/commands/completion/uninstall.ts @@ -1,7 +1,7 @@ import { installedShells } from './helpers.js' export async function uninstallCompletion(): Promise { - // FIXME: Replace with plain tabtab.uninstall({ name: 'cm' }) once + // FIXME: Replace with plain tabtab.uninstall({ name: 'tdc' }) once // https://github.com/pnpm/tabtab/issues/34 is fixed. const shells = installedShells() if (shells.length === 0) { @@ -12,7 +12,7 @@ export async function uninstallCompletion(): Promise { const tabtab = await import('@pnpm/tabtab') for (const shell of shells) { await tabtab.uninstall({ - name: 'cm', + name: 'tdc', shell, }) } diff --git a/src/commands/config/config.test.ts b/src/commands/config/config.test.ts index bdb20eb..fca13ec 100644 --- a/src/commands/config/config.test.ts +++ b/src/commands/config/config.test.ts @@ -82,7 +82,7 @@ describe('config view', () => { mockToken('config-file', { authScope: 'user:read' }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('/tmp/fake-comms-cli/config.json') @@ -104,7 +104,7 @@ describe('config view', () => { mockToken('secure-store', { token: 'tw_keychainXXXXXXXX1234' }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('****…1234') @@ -115,7 +115,7 @@ describe('config view', () => { }) it('labels env-sourced tokens and shows active mode, not stale config values', async () => { - // Config has a stale read-only entry from a previous `cm auth login`, + // Config has a stale read-only entry from a previous `tdc auth login`, // but COMMS_API_TOKEN is now driving auth with an unknown scope. presentConfig({ users: [STORED_ELLIE], @@ -124,7 +124,7 @@ describe('config view', () => { mockToken('env', { token: 'tw_envXXXXXXXX5678', authMode: 'unknown' }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('****…5678') @@ -147,7 +147,7 @@ describe('config view', () => { mockToken('env', { token: 'tw_envXXXXXXXX9999', authMode: 'unknown' }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('/tmp/fake-comms-cli/config.json') @@ -163,7 +163,7 @@ describe('config view', () => { mockToken('config-file') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config']) + await createProgram().parseAsync(['node', 'tdc', 'config']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Authentication') @@ -177,7 +177,7 @@ describe('config view', () => { mockProbeApiToken.mockRejectedValue(new SecureStoreUnavailableError('macOS Keychain error')) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('unknown') @@ -191,7 +191,7 @@ describe('config view', () => { presentConfig() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view', '--json']) const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(parsed.users[0].token).toBe('****…7890') @@ -205,12 +205,19 @@ describe('config view', () => { mockToken('config-file') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--show-token']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view', '--show-token']) const pretty = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(pretty).toContain('tw_abcdefghij1234567890') consoleSpy.mockClear() - await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json', '--show-token']) + await createProgram().parseAsync([ + 'node', + 'tdc', + 'config', + 'view', + '--json', + '--show-token', + ]) const json = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(json.users[0].token).toBe('tw_abcdefghij1234567890') @@ -222,11 +229,11 @@ describe('config view', () => { mockProbeApiToken.mockRejectedValue(new NoTokenError()) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) expect(consoleSpy.mock.calls[0][0]).toContain('not created yet') consoleSpy.mockClear() - await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view', '--json']) expect(consoleSpy.mock.calls[0][0]).toBe('{}') consoleSpy.mockRestore() @@ -243,7 +250,7 @@ describe('config view', () => { mockProbeApiToken.mockRejectedValue(new NoTokenError()) await expect( - createProgram().parseAsync(['node', 'cm', 'config', 'view']), + createProgram().parseAsync(['node', 'tdc', 'config', 'view']), ).rejects.toMatchObject({ code: 'CONFIG_INVALID_JSON' }) }) @@ -252,7 +259,7 @@ describe('config view', () => { mockProbeApiToken.mockRejectedValue(new NoTokenError()) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('not set') expect(output).toContain('stable') @@ -264,7 +271,7 @@ describe('config view', () => { presentConfig({ users: [{ ...STORED_ALAN, token: 'abcd' }] }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view', '--json']) const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(parsed.users[0].token).toBe('****') expect(parsed.users[0].token).not.toContain('abcd') @@ -277,7 +284,7 @@ describe('config view', () => { mockToken('secure-store') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Authenticated accounts (2)') @@ -299,7 +306,7 @@ describe('config view', () => { mockToken('secure-store') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') const adaLine = output.split('\n').find((l) => l.includes('Alan Grant')) ?? '' @@ -317,7 +324,7 @@ describe('config view', () => { mockToken('secure-store') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') const alanLine = output.split('\n').find((l) => l.includes('Alan Grant')) ?? '' @@ -332,7 +339,7 @@ describe('config view', () => { mockToken('secure-store') const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).not.toContain('Authenticated accounts') @@ -346,7 +353,7 @@ describe('config view', () => { }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view', '--json']) const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(parsed.users[0].token).toBe('****…_123') @@ -359,7 +366,14 @@ describe('config view', () => { presentConfig({ users: [{ ...STORED_ALAN, token: 'tw_userA_plaintext_fallback_123' }] }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view', '--json', '--show-token']) + await createProgram().parseAsync([ + 'node', + 'tdc', + 'config', + 'view', + '--json', + '--show-token', + ]) const parsed = JSON.parse(consoleSpy.mock.calls[0][0] as string) expect(parsed.users[0].token).toBe('tw_userA_plaintext_fallback_123') @@ -371,7 +385,7 @@ describe('config view', () => { mockProbeApiToken.mockRejectedValue(new NoTokenError()) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await createProgram().parseAsync(['node', 'cm', 'config', 'view']) + await createProgram().parseAsync(['node', 'tdc', 'config', 'view']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('User settings') @@ -393,7 +407,7 @@ describe('config set', () => { await createProgram().parseAsync([ 'node', - 'cm', + 'tdc', 'config', 'set', 'unarchive-new-threads', @@ -417,7 +431,7 @@ describe('config set', () => { await createProgram().parseAsync([ 'node', - 'cm', + 'tdc', 'config', 'set', 'unarchive-new-threads', @@ -442,7 +456,7 @@ describe('config set', () => { await createProgram().parseAsync([ 'node', - 'cm', + 'tdc', 'config', 'set', 'unarchive-new-threads', @@ -460,7 +474,7 @@ describe('config set', () => { mockReadConfigStrict.mockResolvedValue({ state: 'present', config: {} }) await expect( - createProgram().parseAsync(['node', 'cm', 'config', 'set', 'nope', 'true']), + createProgram().parseAsync(['node', 'tdc', 'config', 'set', 'nope', 'true']), ).rejects.toBeInstanceOf(CliError) expect(mockSetConfig).not.toHaveBeenCalled() }) @@ -471,7 +485,7 @@ describe('config set', () => { await expect( createProgram().parseAsync([ 'node', - 'cm', + 'tdc', 'config', 'set', 'unarchive-new-threads', @@ -487,7 +501,7 @@ describe('config set', () => { await createProgram().parseAsync([ 'node', - 'cm', + 'tdc', 'config', 'set', 'unarchive-new-threads', @@ -506,7 +520,7 @@ describe('config set', () => { await expect( createProgram().parseAsync([ 'node', - 'cm', + 'tdc', 'config', 'set', 'unarchive-new-threads', diff --git a/src/commands/config/index.ts b/src/commands/config/index.ts index 5c8a0d3..579fa8f 100644 --- a/src/commands/config/index.ts +++ b/src/commands/config/index.ts @@ -22,8 +22,8 @@ Settable keys: ${listSettableKeys()} Examples: - $ cm config set unarchive-new-threads true - $ cm config set unarchive-new-threads false`, + $ tdc config set unarchive-new-threads true + $ tdc config set unarchive-new-threads false`, ) .action(setConfigValue) @@ -31,9 +31,9 @@ Examples: 'after', ` Examples: - $ cm config view # pretty-printed, token masked - $ cm config view --json # raw JSON, token masked - $ cm config view --show-token # include the full token - $ cm config set unarchive-new-threads true # change a user preference`, + $ tdc config view # pretty-printed, token masked + $ tdc config view --json # raw JSON, token masked + $ tdc config view --show-token # include the full token + $ tdc config set unarchive-new-threads true # change a user preference`, ) } diff --git a/src/commands/config/view.ts b/src/commands/config/view.ts index 8179e97..3271613 100644 --- a/src/commands/config/view.ts +++ b/src/commands/config/view.ts @@ -107,7 +107,7 @@ function formatConfigView( // When the mode is 'unknown' and no scope is recorded, the scope is // genuinely unintrospectable (env-sourced tokens, or tokens saved via - // `cm auth token` without metadata). Render 'unknown' rather than + // `tdc auth token` without metadata). Render 'unknown' rather than // 'not set' to avoid reading as an explicit empty scope. const scopeDisplay = effectiveMode === 'unknown' && effectiveScope === undefined diff --git a/src/commands/conversation/conversation.test.ts b/src/commands/conversation/conversation.test.ts index c9a4df2..9806f89 100644 --- a/src/commands/conversation/conversation.test.ts +++ b/src/commands/conversation/conversation.test.ts @@ -204,11 +204,11 @@ describe('conversation implicit view', () => { apiMocks.getCommsClient.mockRejectedValue(new Error('MOCK_API_REACHED')) }) - it('cm conversation routes to view (not unknown command)', async () => { + it('tdc conversation routes to view (not unknown command)', async () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await expect(program.parseAsync(['node', 'cm', 'conversation', '100'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'tdc', 'conversation', '100'])).rejects.toThrow( 'MOCK_API_REACHED', ) @@ -227,7 +227,7 @@ describe('conversation unread --workspace conflict', () => { await expect( program.parseAsync([ 'node', - 'cm', + 'tdc', 'conversation', 'unread', 'Doist', @@ -247,7 +247,7 @@ describeEmptyMachineOutput('conversation unread empty output', { }, run: async (extraArgs) => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'conversation', 'unread', ...extraArgs]) + await program.parseAsync(['node', 'tdc', 'conversation', 'unread', ...extraArgs]) }, humanMessage: 'No unread conversations.', }) @@ -275,7 +275,7 @@ describe('conversation with', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'with', 'Alice']) + await program.parseAsync(['node', 'tdc', 'conversation', 'with', 'Alice']) expect(refsMocks.resolveUserRefs).toHaveBeenCalledWith('Alice', 1) expect(refsMocks.resolveConversationId).not.toHaveBeenCalled() @@ -313,7 +313,7 @@ describe('conversation with', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'with', 'Alice']) + await program.parseAsync(['node', 'tdc', 'conversation', 'with', 'Alice']) expect(client.conversations.getConversations).toHaveBeenCalledWith({ workspaceId: 1, @@ -346,7 +346,7 @@ describe('conversation with', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'with', 'Alice']) + await program.parseAsync(['node', 'tdc', 'conversation', 'with', 'Alice']) expect(client.conversations.getConversations).toHaveBeenNthCalledWith(1, { workspaceId: 1, @@ -389,7 +389,7 @@ describe('conversation with', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'conversation', 'with', 'Alice', @@ -420,7 +420,7 @@ describe('conversation with', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'with', 'Me']) + await program.parseAsync(['node', 'tdc', 'conversation', 'with', 'Me']) expect(consoleSpy).toHaveBeenCalledWith('Conversation with Me') @@ -441,7 +441,7 @@ describe('conversation with', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'with', 'Alice', '--json']) + await program.parseAsync(['node', 'tdc', 'conversation', 'with', 'Alice', '--json']) expect(consoleSpy).toHaveBeenCalledTimes(1) expect(JSON.parse(consoleSpy.mock.calls[0][0])).toEqual([]) @@ -460,7 +460,7 @@ describe('conversation with', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'conversation', 'with', 'Alex']), + program.parseAsync(['node', 'tdc', 'conversation', 'with', 'Alex']), ).rejects.toHaveProperty('code', 'AMBIGUOUS_USER') }) }) @@ -498,7 +498,7 @@ describe('conversation view machine output', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'view', '42', '--json']) + await program.parseAsync(['node', 'tdc', 'conversation', 'view', '42', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.conversation).toEqual({ @@ -523,7 +523,7 @@ describe('conversation view machine output', () => { consoleSpy.mockClear() - await program.parseAsync(['node', 'cm', 'conversation', 'view', '42', '--ndjson']) + await program.parseAsync(['node', 'tdc', 'conversation', 'view', '42', '--ndjson']) expect(consoleSpy.mock.calls.map((call) => JSON.parse(call[0]))).toEqual([ { @@ -549,7 +549,7 @@ describe('conversation view machine output', () => { consoleSpy.mockClear() - await program.parseAsync(['node', 'cm', 'conversation', 'view', '42', '--json', '--full']) + await program.parseAsync(['node', 'tdc', 'conversation', 'view', '42', '--json', '--full']) const fullJsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(fullJsonOutput.conversation.participantNames).toEqual(['Me', 'Alice Example']) @@ -600,7 +600,7 @@ describe('conversation view with failed batch response', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'conversation', 'view', '42']), + program.parseAsync(['node', 'tdc', 'conversation', 'view', '42']), ).rejects.toThrow('Failed to fetch user 2: User lookup failed') }) }) @@ -618,7 +618,7 @@ describe('conversation mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'mute', '42']) + await program.parseAsync(['node', 'tdc', 'conversation', 'mute', '42']) expect(client.conversations.muteConversation).toHaveBeenCalledWith({ id: 42, minutes: 60 }) expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 muted for 60 minutes.') @@ -634,7 +634,7 @@ describe('conversation mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'mute', '42', '--minutes', '480']) + await program.parseAsync(['node', 'tdc', 'conversation', 'mute', '42', '--minutes', '480']) expect(client.conversations.muteConversation).toHaveBeenCalledWith({ id: 42, @@ -653,7 +653,7 @@ describe('conversation mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'mute', '42', '--dry-run']) + await program.parseAsync(['node', 'tdc', 'conversation', 'mute', '42', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would mute conversation')) expect(consoleSpy).toHaveBeenCalledWith(' Conversation: conversation 42') @@ -673,7 +673,7 @@ describe('conversation mute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'conversation', 'mute', '42', '--dry-run']), + program.parseAsync(['node', 'tdc', 'conversation', 'mute', '42', '--dry-run']), ).rejects.toThrow('conversation not found') expect(client.conversations.muteConversation).not.toHaveBeenCalled() }) @@ -682,7 +682,7 @@ describe('conversation mute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'conversation', 'mute', '42', '--minutes', 'foo']), + program.parseAsync(['node', 'tdc', 'conversation', 'mute', '42', '--minutes', 'foo']), ).rejects.toHaveProperty('code', 'INVALID_MINUTES') }) }) @@ -700,7 +700,7 @@ describe('conversation unmute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'unmute', '42']) + await program.parseAsync(['node', 'tdc', 'conversation', 'unmute', '42']) expect(client.conversations.unmuteConversation).toHaveBeenCalledWith(42) expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 unmuted.') @@ -716,7 +716,7 @@ describe('conversation unmute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'unmute', '42', '--dry-run']) + await program.parseAsync(['node', 'tdc', 'conversation', 'unmute', '42', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Would unmute conversation'), @@ -737,7 +737,7 @@ describe('conversation unmute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'conversation', 'unmute', '42', '--dry-run']), + program.parseAsync(['node', 'tdc', 'conversation', 'unmute', '42', '--dry-run']), ).rejects.toThrow('conversation not found') expect(client.conversations.unmuteConversation).not.toHaveBeenCalled() }) @@ -756,7 +756,7 @@ describe('conversation done', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'done', '42']) + await program.parseAsync(['node', 'tdc', 'conversation', 'done', '42']) expect(client.conversations.archiveConversation).toHaveBeenCalledWith(42) expect(consoleSpy).toHaveBeenCalledWith('Conversation 42 archived.') @@ -772,7 +772,7 @@ describe('conversation done', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'conversation', 'done', '42', '--dry-run']) + await program.parseAsync(['node', 'tdc', 'conversation', 'done', '42', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Would archive conversation'), @@ -793,7 +793,7 @@ describe('conversation done', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'conversation', 'done', '42', '--dry-run']), + program.parseAsync(['node', 'tdc', 'conversation', 'done', '42', '--dry-run']), ).rejects.toThrow('conversation not found') expect(client.conversations.archiveConversation).not.toHaveBeenCalled() }) diff --git a/src/commands/conversation/index.ts b/src/commands/conversation/index.ts index 6e5da99..3dec8e3 100644 --- a/src/commands/conversation/index.ts +++ b/src/commands/conversation/index.ts @@ -24,8 +24,8 @@ export function registerConversationCommand(program: Command): void { 'after', ` Examples: - cm conversation unread - cm conversation unread --json`, + tdc conversation unread + tdc conversation unread --json`, ) .action(showUnread) @@ -43,9 +43,9 @@ Examples: 'after', ` Examples: - cm conversation 12345 - cm conversation view 12345 --limit 20 - cm conversation view 12345 --since 2025-01-01 --json`, + tdc conversation 12345 + tdc conversation view 12345 --limit 20 + tdc conversation view 12345 --since 2025-01-01 --json`, ) .action((ref, options) => { if (!ref) { @@ -68,9 +68,9 @@ Examples: 'after', ` Examples: - cm conversation with "Jane Smith" - cm conversation with id:5678 --json - cm conversation with "Jane" --include-groups --snippet`, + tdc conversation with "Jane Smith" + tdc conversation with id:5678 --json + tdc conversation with "Jane" --include-groups --snippet`, ) .action(findConversationWithUser) @@ -84,9 +84,9 @@ Examples: 'after', ` Examples: - cm conversation reply 12345 "Hello!" - echo "Message body" | cm conversation reply 12345 - cm conversation reply 12345 "Update" --json`, + tdc conversation reply 12345 "Hello!" + echo "Message body" | tdc conversation reply 12345 + tdc conversation reply 12345 "Update" --json`, ) .action(replyToConversation) @@ -99,8 +99,8 @@ Examples: 'after', ` Examples: - cm conversation done 12345 - cm conversation done 12345 --dry-run`, + tdc conversation done 12345 + tdc conversation done 12345 --dry-run`, ) .action(markConversationDone) @@ -115,8 +115,8 @@ Examples: 'after', ` Examples: - cm conversation mute 12345 - cm conversation mute 12345 --minutes 480`, + tdc conversation mute 12345 + tdc conversation mute 12345 --minutes 480`, ) .action(muteConversation) @@ -130,7 +130,7 @@ Examples: 'after', ` Examples: - cm conversation unmute 12345`, + tdc conversation unmute 12345`, ) .action(unmuteConversation) } diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 46e3a00..cc39a27 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -110,7 +110,7 @@ describe('doctor command', () => { mockFetch('1.0.0') const program = createProgram() - await program.parseAsync(['node', 'cm', 'doctor']) + await program.parseAsync(['node', 'tdc', 'doctor']) expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Node.js v20.19.0')) expect(consoleSpy).toHaveBeenCalledWith( @@ -139,7 +139,7 @@ describe('doctor command', () => { mockFetch('2.0.0') const program = createProgram() - await program.parseAsync(['node', 'cm', 'doctor']) + await program.parseAsync(['node', 'tdc', 'doctor']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -167,7 +167,7 @@ describe('doctor command', () => { mockFetch('1.0.0') const program = createProgram() - await program.parseAsync(['node', 'cm', 'doctor']) + await program.parseAsync(['node', 'tdc', 'doctor']) const configWarning = consoleSpy.mock.calls.find( (call: unknown[]) => @@ -197,7 +197,7 @@ describe('doctor command', () => { mockFetch('1.0.0') const program = createProgram() - await program.parseAsync(['node', 'cm', 'doctor']) + await program.parseAsync(['node', 'tdc', 'doctor']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('PASS CLI is up to date on stable (v1.0.0)'), @@ -209,7 +209,7 @@ describe('doctor command', () => { mockProbeApiToken.mockRejectedValue(new NoTokenError()) const program = createProgram() - await program.parseAsync(['node', 'cm', 'doctor', '--json', '--offline']) + await program.parseAsync(['node', 'tdc', 'doctor', '--json', '--offline']) const output = consoleSpy.mock.calls.at(-1)?.[0] expect(typeof output).toBe('string') @@ -233,7 +233,7 @@ describe('doctor command', () => { it('marks secure-store auth as skipped in offline mode', async () => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'doctor', '--offline']) + await program.parseAsync(['node', 'tdc', 'doctor', '--offline']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -245,7 +245,7 @@ describe('doctor command', () => { it('does not instantiate the API client in offline mode', async () => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'doctor', '--offline']) + await program.parseAsync(['node', 'tdc', 'doctor', '--offline']) expect(mockCreateWrappedCommsClient).not.toHaveBeenCalled() }) @@ -259,7 +259,7 @@ describe('doctor command', () => { mockFetch('1.0.0') const program = createProgram() - await program.parseAsync(['node', 'cm', 'doctor']) + await program.parseAsync(['node', 'tdc', 'doctor']) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('FAIL Node.js v18.0.0 does not satisfy ^20.19.0 || >=22.12.0'), diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 4d1c016..b037411 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -253,7 +253,7 @@ async function checkAuthentication(offline: boolean): Promise { return { name: 'auth', status: 'warn', - message: `No Comms credentials found. Set ${TOKEN_ENV_VAR} or run \`cm auth login\``, + message: `No Comms credentials found. Set ${TOKEN_ENV_VAR} or run \`tdc auth login\``, } } diff --git a/src/commands/groups/groups.test.ts b/src/commands/groups/groups.test.ts index 39e8d91..2d9d3e5 100644 --- a/src/commands/groups/groups.test.ts +++ b/src/commands/groups/groups.test.ts @@ -72,7 +72,7 @@ beforeEach(() => { }) }) -describeEmptyMachineOutput('cm groups list empty output', { +describeEmptyMachineOutput('tdc groups list empty output', { setup: () => { vi.clearAllMocks() apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) @@ -80,17 +80,17 @@ describeEmptyMachineOutput('cm groups list empty output', { }, run: async (extraArgs) => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'groups', ...extraArgs]) + await program.parseAsync(['node', 'tdc', 'groups', ...extraArgs]) }, humanMessage: 'No groups found.', }) -describe('cm groups list (default)', () => { +describe('tdc groups list (default)', () => { it('lists all groups', async () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups']) + await program.parseAsync(['node', 'tdc', 'groups']) expect(consoleSpy).toHaveBeenCalledTimes(3) expect(consoleSpy.mock.calls[0][0]).toContain('Frontend') @@ -100,7 +100,7 @@ describe('cm groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', '--json']) + await program.parseAsync(['node', 'tdc', 'groups', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output).toHaveLength(3) @@ -111,7 +111,7 @@ describe('cm groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', 'list']) + await program.parseAsync(['node', 'tdc', 'groups', 'list']) expect(consoleSpy).toHaveBeenCalledTimes(3) }) @@ -120,7 +120,7 @@ describe('cm groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', '--search', 'front']) + await program.parseAsync(['node', 'tdc', 'groups', '--search', 'front']) expect(consoleSpy).toHaveBeenCalledTimes(1) expect(consoleSpy.mock.calls[0][0]).toContain('Frontend') @@ -131,7 +131,7 @@ describe('cm groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups']) + await program.parseAsync(['node', 'tdc', 'groups']) expect(consoleSpy).toHaveBeenCalledTimes(1) expect(consoleSpy.mock.calls[0][0]).toContain('No groups') @@ -141,7 +141,7 @@ describe('cm groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', '--ndjson']) + await program.parseAsync(['node', 'tdc', 'groups', '--ndjson']) // NDJSON emits all lines via formatNdjson in a single console.log call expect(consoleSpy).toHaveBeenCalledTimes(1) @@ -154,7 +154,7 @@ describe('cm groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', '--json', '--full']) + await program.parseAsync(['node', 'tdc', 'groups', '--json', '--full']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output[0]).toHaveProperty('description') @@ -166,14 +166,14 @@ describe('cm groups list (default)', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', 'list', '1']) + await program.parseAsync(['node', 'tdc', 'groups', 'list', '1']) expect(refsMocks.resolveWorkspaceRef).toHaveBeenCalledWith('1') expect(consoleSpy).toHaveBeenCalled() }) }) -describe('cm groups view', () => { +describe('tdc groups view', () => { const batchUserResponses = [ { code: 200, data: { id: 1, name: 'Alice', email: 'a@d.com' } }, { code: 200, data: { id: 2, name: 'Bob', email: 'b@d.com' } }, @@ -189,7 +189,7 @@ describe('cm groups view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', 'view', 'Frontend']) + await program.parseAsync(['node', 'tdc', 'groups', 'view', 'Frontend']) expect(refsMocks.resolveGroupRef).toHaveBeenCalledWith('Frontend', 1) // Should batch-fetch users, not load all workspace users @@ -204,7 +204,7 @@ describe('cm groups view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', 'view', 'id:100', '--json']) + await program.parseAsync(['node', 'tdc', 'groups', 'view', 'id:100', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.id).toBe(100) @@ -220,7 +220,7 @@ describe('cm groups view', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', 'view', 'id:100', '--json', '--full']) + await program.parseAsync(['node', 'tdc', 'groups', 'view', 'id:100', '--json', '--full']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output.id).toBe(100) @@ -230,7 +230,7 @@ describe('cm groups view', () => { }) }) -describe('cm groups create', () => { +describe('tdc groups create', () => { beforeEach(() => { apiMocks.createGroup.mockResolvedValue({ ...frontend, id: 999, name: 'Design' }) }) @@ -239,7 +239,7 @@ describe('cm groups create', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', 'create', 'Design']) + await program.parseAsync(['node', 'tdc', 'groups', 'create', 'Design']) expect(apiMocks.createGroup).toHaveBeenCalledWith({ workspaceId: 1, @@ -256,7 +256,7 @@ describe('cm groups create', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'groups', 'create', 'Design', @@ -275,12 +275,12 @@ describe('cm groups create', () => { it('rejects empty name', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'groups', 'create', ' ']), + program.parseAsync(['node', 'tdc', 'groups', 'create', ' ']), ).rejects.toMatchObject({ code: 'INVALID_NAME' }) }) }) -describe('cm groups rename', () => { +describe('tdc groups rename', () => { beforeEach(() => { refsMocks.resolveGroupRef.mockResolvedValue(frontend) apiMocks.updateGroup.mockResolvedValue({ ...frontend, name: 'FE Team' }) @@ -290,14 +290,14 @@ describe('cm groups rename', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', 'rename', 'Frontend', 'FE Team']) + await program.parseAsync(['node', 'tdc', 'groups', 'rename', 'Frontend', 'FE Team']) expect(apiMocks.updateGroup).toHaveBeenCalledWith({ id: 100, name: 'FE Team' }) expect(consoleSpy.mock.calls[0][0]).toContain('FE Team') }) }) -describe('cm groups delete', () => { +describe('tdc groups delete', () => { beforeEach(() => { refsMocks.resolveGroupRef.mockResolvedValue(frontend) }) @@ -306,7 +306,7 @@ describe('cm groups delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', 'delete', 'Frontend']) + await program.parseAsync(['node', 'tdc', 'groups', 'delete', 'Frontend']) expect(apiMocks.deleteGroup).not.toHaveBeenCalled() expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('Use --yes'))).toBe(true) @@ -316,7 +316,7 @@ describe('cm groups delete', () => { const program = createProgram() vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', 'delete', 'Frontend', '--yes']) + await program.parseAsync(['node', 'tdc', 'groups', 'delete', 'Frontend', '--yes']) expect(apiMocks.deleteGroup).toHaveBeenCalledWith(100) }) @@ -324,12 +324,12 @@ describe('cm groups delete', () => { it('errors in --json mode without --yes', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'groups', 'delete', 'Frontend', '--json']), + program.parseAsync(['node', 'tdc', 'groups', 'delete', 'Frontend', '--json']), ).rejects.toMatchObject({ code: 'MISSING_YES_FLAG' }) }) }) -describe('cm groups add-user', () => { +describe('tdc groups add-user', () => { beforeEach(() => { refsMocks.resolveGroupRef.mockResolvedValue({ ...frontend, userIds: [1, 2] }) }) @@ -341,7 +341,7 @@ describe('cm groups add-user', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'groups', 'add-user', 'Frontend', @@ -360,7 +360,7 @@ describe('cm groups add-user', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'groups', 'add-user', 'id:100', @@ -376,7 +376,7 @@ describe('cm groups add-user', () => { const program = createProgram() vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', 'add-user', 'Frontend', 'id:1,id:3']) + await program.parseAsync(['node', 'tdc', 'groups', 'add-user', 'Frontend', 'id:1,id:3']) expect(apiMocks.addUsersToGroup).toHaveBeenCalledWith(100, [3]) }) @@ -386,7 +386,7 @@ describe('cm groups add-user', () => { const program = createProgram() vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'groups', 'add-user', 'Frontend', 'id:1,id:2']) + await program.parseAsync(['node', 'tdc', 'groups', 'add-user', 'Frontend', 'id:1,id:2']) expect(apiMocks.addUsersToGroup).not.toHaveBeenCalled() }) @@ -394,7 +394,7 @@ describe('cm groups add-user', () => { it('errors when no user refs given', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'groups', 'add-user', 'Frontend']), + program.parseAsync(['node', 'tdc', 'groups', 'add-user', 'Frontend']), ).rejects.toMatchObject({ code: 'MISSING_USERS' }) }) @@ -405,7 +405,7 @@ describe('cm groups add-user', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'groups', 'add-user', 'Frontend', @@ -418,7 +418,7 @@ describe('cm groups add-user', () => { }) }) -describe('cm groups remove-user', () => { +describe('tdc groups remove-user', () => { beforeEach(() => { refsMocks.resolveGroupRef.mockResolvedValue({ ...frontend, userIds: [1, 2, 3] }) }) @@ -430,7 +430,7 @@ describe('cm groups remove-user', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'groups', 'remove-user', 'Frontend', @@ -447,7 +447,7 @@ describe('cm groups remove-user', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'groups', 'remove-user', 'Frontend', @@ -460,7 +460,7 @@ describe('cm groups remove-user', () => { it('errors when no user refs given', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'groups', 'remove-user', 'Frontend']), + program.parseAsync(['node', 'tdc', 'groups', 'remove-user', 'Frontend']), ).rejects.toMatchObject({ code: 'MISSING_USERS' }) }) @@ -471,7 +471,7 @@ describe('cm groups remove-user', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'groups', 'remove-user', 'Frontend', diff --git a/src/commands/groups/index.ts b/src/commands/groups/index.ts index effffa4..d389fc5 100644 --- a/src/commands/groups/index.ts +++ b/src/commands/groups/index.ts @@ -21,9 +21,9 @@ export function registerGroupsCommand(program: Command): void { 'after', ` Examples: - cm groups - cm groups list --search front - cm groups --workspace 123 --json`, + tdc groups + tdc groups list --search front + tdc groups --workspace 123 --json`, ) .action(listGroups) @@ -37,9 +37,9 @@ Examples: 'after', ` Examples: - cm groups view 12345 - cm groups view "Frontend" - cm groups view id:12345 --json`, + tdc groups view 12345 + tdc groups view "Frontend" + tdc groups view id:12345 --json`, ) .action(viewGroup) @@ -55,9 +55,9 @@ Examples: 'after', ` Examples: - cm groups create "Frontend" - cm groups create "Backend" --users alice@doist.com,bob@doist.com - cm groups create "Design" --users id:123,id:456 --json`, + tdc groups create "Frontend" + tdc groups create "Backend" --users alice@doist.com,bob@doist.com + tdc groups create "Design" --users id:123,id:456 --json`, ) .action(createGroupCommand) @@ -71,8 +71,8 @@ Examples: 'after', ` Examples: - cm groups rename 12345 "Frontend Team" - cm groups rename "Frontend" "Frontend Team" --json`, + tdc groups rename 12345 "Frontend Team" + tdc groups rename "Frontend" "Frontend Team" --json`, ) .action(renameGroup) @@ -86,8 +86,8 @@ Examples: 'after', ` Examples: - cm groups delete 12345 --yes - cm groups delete "Frontend" --dry-run`, + tdc groups delete 12345 --yes + tdc groups delete "Frontend" --dry-run`, ) .action(deleteGroupCommand) @@ -101,9 +101,9 @@ Examples: 'after', ` Examples: - cm groups add-user 12345 alice@doist.com bob@doist.com - cm groups add-user "Frontend" id:123,id:456 - cm groups add-user 12345 alice bob carol --json + tdc groups add-user 12345 alice@doist.com bob@doist.com + tdc groups add-user "Frontend" id:123,id:456 + tdc groups add-user 12345 alice bob carol --json User references can be passed as space-separated args, comma-separated within a single arg, or any mix of the two.`, @@ -120,8 +120,8 @@ single arg, or any mix of the two.`, 'after', ` Examples: - cm groups remove-user 12345 alice@doist.com - cm groups remove-user "Frontend" id:123,id:456`, + tdc groups remove-user 12345 alice@doist.com + tdc groups remove-user "Frontend" id:123,id:456`, ) .action(removeUsersCommand) } diff --git a/src/commands/inbox.test.ts b/src/commands/inbox.test.ts index 90548d3..c02f91a 100644 --- a/src/commands/inbox.test.ts +++ b/src/commands/inbox.test.ts @@ -45,7 +45,7 @@ describe('inbox --workspace conflict', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'inbox', 'Doist', '--workspace', 'Other']), + program.parseAsync(['node', 'tdc', 'inbox', 'Doist', '--workspace', 'Other']), ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') }) }) @@ -73,7 +73,7 @@ describe('inbox --archive-filter', () => { it('passes archiveFilter to SDK getInbox', async () => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'inbox', '--archive-filter', 'all', '--json']) + await program.parseAsync(['node', 'tdc', 'inbox', '--archive-filter', 'all', '--json']) expect(mockGetInbox).toHaveBeenCalledWith( expect.objectContaining({ archiveFilter: 'all' }), @@ -83,7 +83,7 @@ describe('inbox --archive-filter', () => { it('defaults archiveFilter to active when not provided', async () => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'inbox', '--json']) + await program.parseAsync(['node', 'tdc', 'inbox', '--json']) expect(mockGetInbox).toHaveBeenCalledWith( expect.objectContaining({ archiveFilter: 'active' }), @@ -95,7 +95,7 @@ describe('inbox --archive-filter', () => { const program = createProgram() await program.parseAsync([ 'node', - 'cm', + 'tdc', 'inbox', '--since', '2026-01-01', @@ -138,7 +138,7 @@ describeEmptyMachineOutput('inbox empty output', { }, run: async (extraArgs) => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'inbox', ...extraArgs]) + await program.parseAsync(['node', 'tdc', 'inbox', ...extraArgs]) }, humanMessage: 'No threads in inbox.', }) @@ -174,7 +174,7 @@ describe('inbox empty output (channel filter)', () => { it('outputs [] for --json when --channel filter matches nothing', async () => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'inbox', '--channel', 'nonexistent', '--json']) + await program.parseAsync(['node', 'tdc', 'inbox', '--channel', 'nonexistent', '--json']) expect(logSpy).toHaveBeenCalledTimes(1) expect(logSpy).toHaveBeenCalledWith('[]') @@ -209,7 +209,7 @@ describe('inbox batch errors', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'inbox', '--unread', '--limit', '1000']), + program.parseAsync(['node', 'tdc', 'inbox', '--unread', '--limit', '1000']), ).rejects.toThrow('Failed to fetch inbox threads: limit must be less than or equal to 500') }) @@ -245,7 +245,7 @@ describe('inbox batch errors', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const program = createProgram() - await program.parseAsync(['node', 'cm', 'inbox', '--json']) + await program.parseAsync(['node', 'tdc', 'inbox', '--json']) const output = JSON.parse(consoleSpy.mock.calls[0][0]) expect(output).toHaveLength(1) diff --git a/src/commands/inbox.ts b/src/commands/inbox.ts index d4bf54a..4dfffe2 100644 --- a/src/commands/inbox.ts +++ b/src/commands/inbox.ts @@ -193,12 +193,12 @@ export function registerInboxCommand(program: Command): void { 'after', ` Examples: - cm inbox - cm inbox --unread - cm inbox --archive-filter all - cm inbox --archive-filter archived - cm inbox --channel engineering --since 2025-01-01 - cm inbox --limit 10 --json`, + tdc inbox + tdc inbox --unread + tdc inbox --archive-filter all + tdc inbox --archive-filter archived + tdc inbox --channel engineering --since 2025-01-01 + tdc inbox --limit 10 --json`, ) .action(showInbox) } diff --git a/src/commands/mentions.test.ts b/src/commands/mentions.test.ts index e8f5f6d..d7b3ae7 100644 --- a/src/commands/mentions.test.ts +++ b/src/commands/mentions.test.ts @@ -55,7 +55,7 @@ describe('mentions', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'mentions', 'Doist', '--workspace', 'Other']), + program.parseAsync(['node', 'tdc', 'mentions', 'Doist', '--workspace', 'Other']), ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') }) @@ -63,7 +63,7 @@ describe('mentions', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'mentions']) + await program.parseAsync(['node', 'tdc', 'mentions']) expect(searchApiMocks.extendedSearch).toHaveBeenCalledWith( expect.objectContaining({ @@ -94,7 +94,7 @@ describe('mentions', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'mentions', '--all']) + await program.parseAsync(['node', 'tdc', 'mentions', '--all']) expect(searchApiMocks.extendedSearch).toHaveBeenNthCalledWith( 1, @@ -118,7 +118,7 @@ describe('mentions', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'mentions', '--json']) + await program.parseAsync(['node', 'tdc', 'mentions', '--json']) expect(logSpy).toHaveBeenCalledTimes(1) expect(JSON.parse(logSpy.mock.calls[0][0])).toEqual({ @@ -133,7 +133,7 @@ describe('mentions', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'mentions', '--ndjson']) + await program.parseAsync(['node', 'tdc', 'mentions', '--ndjson']) expect(logSpy).toHaveBeenCalledTimes(1) expect(JSON.parse(logSpy.mock.calls[0][0])).toEqual({ diff --git a/src/commands/mentions.ts b/src/commands/mentions.ts index 2591c55..63329a8 100644 --- a/src/commands/mentions.ts +++ b/src/commands/mentions.ts @@ -33,9 +33,9 @@ export function registerMentionsCommand(program: Command): void { 'after', ` Examples: - cm mentions - cm mentions --since 2026-04-01 --all - cm mentions --type threads --json`, + tdc mentions + tdc mentions --since 2026-04-01 --all + tdc mentions --type threads --json`, ) .action(mentions) } diff --git a/src/commands/msg/index.ts b/src/commands/msg/index.ts index 61b9bab..9258c80 100644 --- a/src/commands/msg/index.ts +++ b/src/commands/msg/index.ts @@ -19,8 +19,8 @@ export function registerMsgCommand(program: Command): void { 'after', ` Examples: - cm msg 12345 - cm msg view 12345 --json`, + tdc msg 12345 + tdc msg view 12345 --json`, ) .action((ref, options) => { if (!ref) { @@ -39,9 +39,9 @@ Examples: 'after', ` Examples: - cm msg update 12345 "Updated text" - echo "New content" | cm msg update 12345 - cm msg update 12345 "Fixed typo" --json`, + tdc msg update 12345 "Updated text" + echo "New content" | tdc msg update 12345 + tdc msg update 12345 "Fixed typo" --json`, ) .action(updateMessage) @@ -53,8 +53,8 @@ Examples: 'after', ` Examples: - cm msg delete 12345 - cm msg delete 12345 --dry-run`, + tdc msg delete 12345 + tdc msg delete 12345 --dry-run`, ) .action(deleteMessage) } diff --git a/src/commands/msg/msg.test.ts b/src/commands/msg/msg.test.ts index 706eba5..3134578 100644 --- a/src/commands/msg/msg.test.ts +++ b/src/commands/msg/msg.test.ts @@ -85,11 +85,11 @@ describe('msg implicit view', () => { apiMocks.getCommsClient.mockRejectedValue(new Error('MOCK_API_REACHED')) }) - it('cm msg routes to view (not unknown command)', async () => { + it('tdc msg routes to view (not unknown command)', async () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await expect(program.parseAsync(['node', 'cm', 'msg', '200'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'tdc', 'msg', '200'])).rejects.toThrow( 'MOCK_API_REACHED', ) @@ -108,7 +108,7 @@ describe('msg delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'msg', 'delete', '200']) + await program.parseAsync(['node', 'tdc', 'msg', 'delete', '200']) expect(client.conversationMessages.deleteMessage).toHaveBeenCalledWith(200) expect(consoleSpy).toHaveBeenCalledWith('Message 200 deleted.') @@ -121,7 +121,7 @@ describe('msg delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'msg', 'delete', '200', '--dry-run']) + await program.parseAsync(['node', 'tdc', 'msg', 'delete', '200', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would delete message')) expect(consoleSpy).toHaveBeenCalledWith(' Message: 200') @@ -136,7 +136,7 @@ describe('msg delete', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'msg', 'delete', '200', '--dry-run']), + program.parseAsync(['node', 'tdc', 'msg', 'delete', '200', '--dry-run']), ).rejects.toHaveProperty('code', 'NOT_CREATOR') expect(client.conversationMessages.deleteMessage).not.toHaveBeenCalled() }) @@ -147,7 +147,7 @@ describe('msg delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'msg', 'delete', '200', '--json']) + await program.parseAsync(['node', 'tdc', 'msg', 'delete', '200', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual({ id: 200, deleted: true }) diff --git a/src/commands/react.test.ts b/src/commands/react.test.ts index cbd5008..922a941 100644 --- a/src/commands/react.test.ts +++ b/src/commands/react.test.ts @@ -39,7 +39,7 @@ describe('react refs', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'react', 'thread', 'https://comms.todoist.com/a/1/ch/2/t/99', @@ -56,7 +56,7 @@ describe('react refs', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'unreact', 'message', 'https://comms.todoist.com/a/1/msg/33/m/44', @@ -71,7 +71,7 @@ describe('react refs', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'react', 'thread', '99', '+1', '--json']) + await program.parseAsync(['node', 'tdc', 'react', 'thread', '99', '+1', '--json']) expect(apiMocks.addReaction).toHaveBeenCalledWith({ threadId: 99, reaction: '👍' }) const output = JSON.parse(logSpy.mock.calls[0][0]) @@ -88,7 +88,7 @@ describe('react refs', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'unreact', 'comment', '42', 'heart', '--json']) + await program.parseAsync(['node', 'tdc', 'unreact', 'comment', '42', 'heart', '--json']) expect(apiMocks.removeReaction).toHaveBeenCalledWith({ commentId: 42, reaction: '❤️' }) const output = JSON.parse(logSpy.mock.calls[0][0]) @@ -107,7 +107,7 @@ describe('react refs', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'react', 'message', '77', diff --git a/src/commands/react.ts b/src/commands/react.ts index ef19dce..63bac63 100644 --- a/src/commands/react.ts +++ b/src/commands/react.ts @@ -159,10 +159,10 @@ export function registerReactCommand(program: Command): void { 'after', ` Examples: - cm react thread 12345 +1 - cm react comment 67890 heart - cm react message 11111 tada --dry-run - cm react thread 12345 +1 --json`, + tdc react thread 12345 +1 + tdc react comment 67890 heart + tdc react message 11111 tada --dry-run + tdc react thread 12345 +1 --json`, ) .action((targetType: string, targetRef: string, emoji: string, options: ReactOptions) => { if (!['thread', 'comment', 'message'].includes(targetType)) { @@ -183,9 +183,9 @@ Examples: 'after', ` Examples: - cm unreact thread 12345 +1 - cm unreact comment 67890 heart - cm unreact thread 12345 +1 --json`, + tdc unreact thread 12345 +1 + tdc unreact comment 67890 heart + tdc unreact thread 12345 +1 --json`, ) .action((targetType: string, targetRef: string, emoji: string, options: ReactOptions) => { if (!['thread', 'comment', 'message'].includes(targetType)) { diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 1d801c2..67bb111 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -55,7 +55,7 @@ describe('search --workspace conflict', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'search', 'query', 'Doist', '--workspace', 'Other']), + program.parseAsync(['node', 'tdc', 'search', 'query', 'Doist', '--workspace', 'Other']), ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') }) @@ -75,7 +75,7 @@ describe('search --workspace conflict', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await program.parseAsync([ 'node', - 'cm', + 'tdc', 'search', 'query', '--channel', @@ -111,7 +111,7 @@ describe('search --workspace conflict', () => { const program = createProgram() const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'search', 'query', '--all']) + await program.parseAsync(['node', 'tdc', 'search', 'query', '--all']) expect(searchApiMocks.extendedSearch).toHaveBeenNthCalledWith( 1, diff --git a/src/commands/search.ts b/src/commands/search.ts index 5612da3..85b2b80 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -44,10 +44,10 @@ export function registerSearchCommand(program: Command): void { 'after', ` Examples: - cm search "deployment issue" - cm search "bug report" --type threads --channel id:12345 - cm search "API" --author id:5678 --since 2025-01-01 --json - cm search "incident" --all --json`, + tdc search "deployment issue" + tdc search "bug report" --type threads --channel id:12345 + tdc search "API" --author id:5678 --since 2025-01-01 --json + tdc search "incident" --all --json`, ) .action(search) } diff --git a/src/commands/skill/install.ts b/src/commands/skill/install.ts index 76cc43c..c32afb5 100644 --- a/src/commands/skill/install.ts +++ b/src/commands/skill/install.ts @@ -8,7 +8,7 @@ export async function install(agentName: string, options: InstallOptions): Promi if (!installer) { throw new CliError('UNKNOWN_AGENT', `Unknown agent: ${agentName}`, [ - 'Run `cm skill list` to see available agents', + 'Run `tdc skill list` to see available agents', ]) } diff --git a/src/commands/skill/skill.test.ts b/src/commands/skill/skill.test.ts index 6a2b484..0338664 100644 --- a/src/commands/skill/skill.test.ts +++ b/src/commands/skill/skill.test.ts @@ -253,13 +253,13 @@ describe('skill command', () => { it('lists agents', async () => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'skill', 'list', '--local']) + await program.parseAsync(['node', 'tdc', 'skill', 'list', '--local']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Available agents')) }) it('installs agent locally', async () => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'skill', 'install', 'claude-code', '--local']) + await program.parseAsync(['node', 'tdc', 'skill', 'install', 'claude-code', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Installed')) const skillPath = join(testDir, '.claude', 'skills', 'comms-cli', 'SKILL.md') @@ -271,7 +271,7 @@ describe('skill command', () => { it('installs codex agent locally', async () => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'skill', 'install', 'codex', '--local']) + await program.parseAsync(['node', 'tdc', 'skill', 'install', 'codex', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Installed')) const skillPath = join(testDir, '.codex', 'skills', 'comms-cli', 'SKILL.md') @@ -281,7 +281,7 @@ describe('skill command', () => { it('installs cursor agent locally', async () => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'skill', 'install', 'cursor', '--local']) + await program.parseAsync(['node', 'tdc', 'skill', 'install', 'cursor', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Installed')) const skillPath = join(testDir, '.cursor', 'skills', 'comms-cli', 'SKILL.md') @@ -294,7 +294,7 @@ describe('skill command', () => { await installer.install({ local: true }) const program = createProgram() - await program.parseAsync(['node', 'cm', 'skill', 'uninstall', 'claude-code', '--local']) + await program.parseAsync(['node', 'tdc', 'skill', 'uninstall', 'claude-code', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Uninstalled')) }) @@ -302,7 +302,7 @@ describe('skill command', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'skill', 'install', 'unknown-agent', '--local']), + program.parseAsync(['node', 'tdc', 'skill', 'install', 'unknown-agent', '--local']), ).rejects.toHaveProperty('code', 'UNKNOWN_AGENT') }) @@ -311,7 +311,7 @@ describe('skill command', () => { await installer.install({ local: true }) const program = createProgram() - await program.parseAsync(['node', 'cm', 'skill', 'update', 'claude-code', '--local']) + await program.parseAsync(['node', 'tdc', 'skill', 'update', 'claude-code', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Updated')) }) @@ -319,7 +319,7 @@ describe('skill command', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'skill', 'update', 'unknown-agent', '--local']), + program.parseAsync(['node', 'tdc', 'skill', 'update', 'unknown-agent', '--local']), ).rejects.toHaveProperty('code', 'UNKNOWN_AGENT') }) @@ -328,7 +328,7 @@ describe('skill command', () => { await skillInstallers.codex.install({ local: true }) const program = createProgram() - await program.parseAsync(['node', 'cm', 'skill', 'update', 'all', '--local']) + await program.parseAsync(['node', 'tdc', 'skill', 'update', 'all', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Updated claude-code')) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Updated codex')) }) @@ -337,7 +337,7 @@ describe('skill command', () => { await skillInstallers.cursor.install({ local: true }) const program = createProgram() - await program.parseAsync(['node', 'cm', 'skill', 'update', '--local']) + await program.parseAsync(['node', 'tdc', 'skill', 'update', '--local']) expect(consoleSpy).toHaveBeenCalledWith('✓', expect.stringContaining('Updated cursor')) }) }) diff --git a/src/commands/skill/uninstall.ts b/src/commands/skill/uninstall.ts index db0e0f9..0a49d0c 100644 --- a/src/commands/skill/uninstall.ts +++ b/src/commands/skill/uninstall.ts @@ -8,7 +8,7 @@ export async function uninstall(agentName: string, options: UninstallOptions): P if (!installer) { throw new CliError('UNKNOWN_AGENT', `Unknown agent: ${agentName}`, [ - 'Run `cm skill list` to see available agents', + 'Run `tdc skill list` to see available agents', ]) } diff --git a/src/commands/skill/update.ts b/src/commands/skill/update.ts index 6c8ce8f..ec36a35 100644 --- a/src/commands/skill/update.ts +++ b/src/commands/skill/update.ts @@ -33,7 +33,7 @@ export async function updateSkill(agentName: string, options: UpdateOptions): Pr if (!installer) { throw new CliError('UNKNOWN_AGENT', `Unknown agent: ${agentName}`, [ - 'Run `cm skill list` to see available agents', + 'Run `tdc skill list` to see available agents', ]) } diff --git a/src/commands/thread/index.ts b/src/commands/thread/index.ts index c2a06b2..86f956e 100644 --- a/src/commands/thread/index.ts +++ b/src/commands/thread/index.ts @@ -29,9 +29,9 @@ export function registerThreadCommand(program: Command): void { 'after', ` Examples: - cm thread 12345 - cm thread view 12345 --unread - cm thread view 12345 --limit 10 --json`, + tdc thread 12345 + tdc thread view 12345 --unread + tdc thread view 12345 --limit 10 --json`, ) .action((ref, options) => { if (!ref) { @@ -62,9 +62,9 @@ Examples: 'after', ` Examples: - cm thread reply 12345 "Sounds good!" - echo "Long reply" | cm thread reply 12345 - cm thread reply 12345 "Done" --close --json`, + tdc thread reply 12345 "Sounds good!" + echo "Long reply" | tdc thread reply 12345 + tdc thread reply 12345 "Done" --close --json`, ) .action(replyToThread) @@ -84,10 +84,10 @@ Examples: 'after', ` Examples: - cm thread create 12345 "Weekly update" "Here's what happened..." - echo "Body from stdin" | cm thread create id:12345 "Title" - cm thread create 12345 "Title" "Body" --notify 67890,11111 --json - cm thread create 12345 "Title" "Body" --unarchive`, + tdc thread create 12345 "Weekly update" "Here's what happened..." + echo "Body from stdin" | tdc thread create id:12345 "Title" + tdc thread create 12345 "Title" "Body" --notify 67890,11111 --json + tdc thread create 12345 "Title" "Body" --unarchive`, ) .action(createThread) @@ -100,9 +100,9 @@ Examples: 'after', ` Examples: - cm thread done 12345 - cm thread done 12345 --dry-run - cm thread done 12345 --json`, + tdc thread done 12345 + tdc thread done 12345 --dry-run + tdc thread done 12345 --json`, ) .action(markThreadDone) @@ -116,9 +116,9 @@ Examples: 'after', ` Examples: - cm thread delete 12345 --yes - cm thread delete 12345 --dry-run - cm thread delete 12345 --yes --json`, + tdc thread delete 12345 --yes + tdc thread delete 12345 --dry-run + tdc thread delete 12345 --yes --json`, ) .action(deleteThread) @@ -133,8 +133,8 @@ Examples: 'after', ` Examples: - cm thread mute 12345 - cm thread mute 12345 --minutes 480`, + tdc thread mute 12345 + tdc thread mute 12345 --minutes 480`, ) .action(muteThread) @@ -156,9 +156,9 @@ Examples: 'after', ` Examples: - cm thread update 12345 "Updated body text" - echo "New body" | cm thread update 12345 - cm thread update 12345 "Fixed" --json`, + tdc thread update 12345 "Updated body text" + echo "New body" | tdc thread update 12345 + tdc thread update 12345 "Fixed" --json`, ) .action(updateThread) @@ -172,7 +172,7 @@ Examples: 'after', ` Examples: - cm thread unmute 12345`, + tdc thread unmute 12345`, ) .action(unmuteThread) } diff --git a/src/commands/thread/thread.test.ts b/src/commands/thread/thread.test.ts index 86bdf82..e5e4825 100644 --- a/src/commands/thread/thread.test.ts +++ b/src/commands/thread/thread.test.ts @@ -208,13 +208,13 @@ describe('thread implicit view', () => { apiMocks.getCommsClient.mockRejectedValue(new Error('MOCK_API_REACHED')) }) - it('cm thread routes to view (not unknown command)', async () => { + it('tdc thread routes to view (not unknown command)', async () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) // If Commander routes to view, it will call getCommsClient which throws MOCK_API_REACHED. // If it doesn't route, Commander throws "unknown command '100'". - await expect(program.parseAsync(['node', 'cm', 'thread', '100'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'tdc', 'thread', '100'])).rejects.toThrow( 'MOCK_API_REACHED', ) @@ -231,7 +231,7 @@ describe('thread implicit view', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'reply', '100', @@ -257,7 +257,7 @@ describe('thread implicit view', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'reply', '100', @@ -282,7 +282,7 @@ describe('thread implicit view', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'reply', '100', @@ -305,7 +305,7 @@ describe('thread implicit view', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) vi.mocked(readStdin).mockResolvedValueOnce('closing comment') - await program.parseAsync(['node', 'cm', 'thread', 'reply', '500', '--close']) + await program.parseAsync(['node', 'tdc', 'thread', 'reply', '500', '--close']) expect(client.threads.closeThread).toHaveBeenCalledWith( expect.objectContaining({ id: 500, content: 'closing comment' }), @@ -322,7 +322,7 @@ describe('thread implicit view', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) vi.mocked(readStdin).mockResolvedValueOnce('reopening comment') - await program.parseAsync(['node', 'cm', 'thread', 'reply', '500', '--reopen']) + await program.parseAsync(['node', 'tdc', 'thread', 'reply', '500', '--reopen']) expect(client.threads.reopenThread).toHaveBeenCalledWith( expect.objectContaining({ id: 500, content: 'reopening comment' }), @@ -337,7 +337,7 @@ describe('thread implicit view', () => { await expect( program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'reply', '100', @@ -365,7 +365,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--unread']) + await program.parseAsync(['node', 'tdc', 'thread', 'view', '500', '--unread']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Test Thread') @@ -386,7 +386,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--unread']) + await program.parseAsync(['node', 'tdc', 'thread', 'view', '500', '--unread']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') // Should show original post @@ -413,7 +413,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--unread', '--json']) + await program.parseAsync(['node', 'tdc', 'thread', 'view', '500', '--unread', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.thread.id).toBe(500) @@ -435,7 +435,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--unread', '--json']) + await program.parseAsync(['node', 'tdc', 'thread', 'view', '500', '--unread', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.thread.id).toBe(500) @@ -455,7 +455,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--unread', '--ndjson']) + await program.parseAsync(['node', 'tdc', 'thread', 'view', '500', '--unread', '--ndjson']) const lines = consoleSpy.mock.calls.map((c) => JSON.parse(c[0])) // First line is the thread @@ -480,7 +480,7 @@ describe('thread view --unread', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--json']) + await program.parseAsync(['node', 'tdc', 'thread', 'view', '500', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) // Without --unread, all comments are returned @@ -509,7 +509,7 @@ describe('thread view --since', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'view', '500', @@ -552,7 +552,7 @@ describe('thread view with failed batch response', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await expect( - program.parseAsync(['node', 'cm', 'thread', 'view', '500', '--comment', '99999']), + program.parseAsync(['node', 'tdc', 'thread', 'view', '500', '--comment', '99999']), ).rejects.toThrow('Failed to fetch comment 99999.') consoleSpy.mockRestore() @@ -570,7 +570,7 @@ describe('thread view with failed batch response', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await expect(program.parseAsync(['node', 'cm', 'thread', 'view', '500'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'tdc', 'thread', 'view', '500'])).rejects.toThrow( 'Failed to fetch thread.', ) @@ -606,7 +606,7 @@ describe('thread view with failed user batch response', () => { const program = createProgram() - await expect(program.parseAsync(['node', 'cm', 'thread', 'view', '500'])).rejects.toThrow( + await expect(program.parseAsync(['node', 'tdc', 'thread', 'view', '500'])).rejects.toThrow( 'Failed to fetch user 2: User lookup failed', ) }) @@ -632,7 +632,7 @@ describe('thread view with failed user batch response', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'view', '500']) + await program.parseAsync(['node', 'tdc', 'thread', 'view', '500']) const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n') expect(output).toContain('Alice') @@ -657,7 +657,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'create', '100', @@ -686,7 +686,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'create', '100', @@ -711,7 +711,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'create', '100', @@ -735,7 +735,7 @@ describe('thread create', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'create', '100', 'My Title']) + await program.parseAsync(['node', 'tdc', 'thread', 'create', '100', 'My Title']) expect(client.threads.createThread).toHaveBeenCalledWith( expect.objectContaining({ @@ -762,7 +762,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'create', '100', @@ -796,7 +796,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'create', '100', @@ -822,7 +822,7 @@ describe('thread create', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'thread', 'create', '100', 'My Title']), + program.parseAsync(['node', 'tdc', 'thread', 'create', '100', 'My Title']), ).rejects.toHaveProperty('code', 'MISSING_CONTENT') }) @@ -833,7 +833,7 @@ describe('thread create', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'create', '100', 'T', 'body']) + await program.parseAsync(['node', 'tdc', 'thread', 'create', '100', 'T', 'body']) expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() consoleSpy.mockRestore() @@ -848,7 +848,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'create', '100', @@ -871,7 +871,7 @@ describe('thread create', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'create', '100', 'T', 'body']) + await program.parseAsync(['node', 'tdc', 'thread', 'create', '100', 'T', 'body']) expect(client.inbox.unarchiveThread).toHaveBeenCalledWith(999) consoleSpy.mockRestore() @@ -889,7 +889,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'create', '100', @@ -913,7 +913,7 @@ describe('thread create', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'create', '100', @@ -942,7 +942,7 @@ describe('thread mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'mute', '500']) + await program.parseAsync(['node', 'tdc', 'thread', 'mute', '500']) expect(client.threads.muteThread).toHaveBeenCalledWith({ id: 500, minutes: 60 }) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 muted for 60 minutes.') @@ -957,7 +957,7 @@ describe('thread mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'mute', '500', '--minutes', '480']) + await program.parseAsync(['node', 'tdc', 'thread', 'mute', '500', '--minutes', '480']) expect(client.threads.muteThread).toHaveBeenCalledWith({ id: 500, minutes: 480 }) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 muted for 480 minutes.') @@ -972,7 +972,7 @@ describe('thread mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'mute', '500', '--dry-run']) + await program.parseAsync(['node', 'tdc', 'thread', 'mute', '500', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would mute thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') @@ -990,7 +990,7 @@ describe('thread mute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'thread', 'mute', '500', '--dry-run']), + program.parseAsync(['node', 'tdc', 'thread', 'mute', '500', '--dry-run']), ).rejects.toThrow('thread not found') expect(client.threads.muteThread).not.toHaveBeenCalled() }) @@ -1002,7 +1002,7 @@ describe('thread mute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'mute', '500', '--json']) + await program.parseAsync(['node', 'tdc', 'thread', 'mute', '500', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(500) @@ -1016,7 +1016,7 @@ describe('thread mute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'thread', 'mute', '500', '--minutes', 'foo']), + program.parseAsync(['node', 'tdc', 'thread', 'mute', '500', '--minutes', 'foo']), ).rejects.toHaveProperty('code', 'INVALID_MINUTES') }) }) @@ -1033,7 +1033,7 @@ describe('thread unmute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'unmute', '500']) + await program.parseAsync(['node', 'tdc', 'thread', 'unmute', '500']) expect(client.threads.unmuteThread).toHaveBeenCalledWith(500) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 unmuted.') @@ -1048,7 +1048,7 @@ describe('thread unmute', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'unmute', '500', '--dry-run']) + await program.parseAsync(['node', 'tdc', 'thread', 'unmute', '500', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would unmute thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') @@ -1065,7 +1065,7 @@ describe('thread unmute', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'thread', 'unmute', '500', '--dry-run']), + program.parseAsync(['node', 'tdc', 'thread', 'unmute', '500', '--dry-run']), ).rejects.toThrow('thread not found') expect(client.threads.unmuteThread).not.toHaveBeenCalled() }) @@ -1082,7 +1082,7 @@ describe('thread delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'delete', '500', '--yes']) + await program.parseAsync(['node', 'tdc', 'thread', 'delete', '500', '--yes']) expect(client.threads.deleteThread).toHaveBeenCalledWith(500) expect(consoleSpy).toHaveBeenCalledWith('Thread Test Thread (500) deleted.') @@ -1095,7 +1095,7 @@ describe('thread delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'delete', '500']) + await program.parseAsync(['node', 'tdc', 'thread', 'delete', '500']) expect(consoleSpy).toHaveBeenCalledWith('Would delete: Test Thread') expect(consoleSpy).toHaveBeenCalledWith('Use --yes to confirm.') @@ -1109,7 +1109,7 @@ describe('thread delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'delete', '500', '--dry-run']) + await program.parseAsync(['node', 'tdc', 'thread', 'delete', '500', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would delete thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') @@ -1123,7 +1123,7 @@ describe('thread delete', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'delete', '500', '--json', '--yes']) + await program.parseAsync(['node', 'tdc', 'thread', 'delete', '500', '--json', '--yes']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput).toEqual({ id: 500, deleted: true }) @@ -1136,7 +1136,7 @@ describe('thread delete', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'thread', 'delete', '500', '--json']), + program.parseAsync(['node', 'tdc', 'thread', 'delete', '500', '--json']), ).rejects.toHaveProperty('code', 'MISSING_YES_FLAG') expect(client.threads.deleteThread).not.toHaveBeenCalled() @@ -1148,7 +1148,7 @@ describe('thread delete', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'thread', 'delete', '500', '--yes']), + program.parseAsync(['node', 'tdc', 'thread', 'delete', '500', '--yes']), ).rejects.toHaveProperty('code', 'NOT_CREATOR') expect(client.threads.deleteThread).not.toHaveBeenCalled() @@ -1167,7 +1167,7 @@ describe('thread rename', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'rename', '500', 'New Title']) + await program.parseAsync(['node', 'tdc', 'thread', 'rename', '500', 'New Title']) expect(client.threads.updateThread).toHaveBeenCalledWith({ id: 500, title: 'New Title' }) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 renamed to "New Title".') @@ -1184,7 +1184,7 @@ describe('thread rename', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'rename', '500', @@ -1208,7 +1208,15 @@ describe('thread rename', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'thread', 'rename', '500', 'New Title', '--dry-run']), + program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'rename', + '500', + 'New Title', + '--dry-run', + ]), ).rejects.toThrow('thread not found') expect(client.threads.updateThread).not.toHaveBeenCalled() }) @@ -1220,7 +1228,7 @@ describe('thread rename', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'rename', '500', 'New Title', '--json']) + await program.parseAsync(['node', 'tdc', 'thread', 'rename', '500', 'New Title', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(500) @@ -1239,7 +1247,7 @@ describe('thread rename', () => { await program.parseAsync([ 'node', - 'cm', + 'tdc', 'thread', 'rename', '500', @@ -1270,7 +1278,7 @@ describe('thread update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'update', '500', 'New body']) + await program.parseAsync(['node', 'tdc', 'thread', 'update', '500', 'New body']) expect(client.threads.updateThread).toHaveBeenCalledWith({ id: 500, @@ -1288,7 +1296,15 @@ describe('thread update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'update', '500', 'New body', '--dry-run']) + await program.parseAsync([ + 'node', + 'tdc', + 'thread', + 'update', + '500', + 'New body', + '--dry-run', + ]) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would update thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') @@ -1306,7 +1322,7 @@ describe('thread update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'update', '500']) + await program.parseAsync(['node', 'tdc', 'thread', 'update', '500']) expect(client.threads.updateThread).toHaveBeenCalledWith({ id: 500, @@ -1321,7 +1337,7 @@ describe('thread update', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await expect( - program.parseAsync(['node', 'cm', 'thread', 'update', '500']), + program.parseAsync(['node', 'tdc', 'thread', 'update', '500']), ).rejects.toHaveProperty('code', 'MISSING_CONTENT') consoleSpy.mockRestore() @@ -1334,7 +1350,7 @@ describe('thread update', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'update', '500', 'New body', '--json']) + await program.parseAsync(['node', 'tdc', 'thread', 'update', '500', 'New body', '--json']) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) expect(jsonOutput.id).toBe(500) @@ -1352,7 +1368,7 @@ describe('thread update', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'thread', 'update', '500', 'New body', '--dry-run']), + program.parseAsync(['node', 'tdc', 'thread', 'update', '500', 'New body', '--dry-run']), ).rejects.toThrow('thread not found') expect(client.threads.updateThread).not.toHaveBeenCalled() }) @@ -1370,7 +1386,7 @@ describe('thread done', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'done', '500']) + await program.parseAsync(['node', 'tdc', 'thread', 'done', '500']) expect(client.inbox.archiveThread).toHaveBeenCalledWith(500) expect(consoleSpy).toHaveBeenCalledWith('Thread 500 archived.') @@ -1385,7 +1401,7 @@ describe('thread done', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'thread', 'done', '500', '--dry-run']) + await program.parseAsync(['node', 'tdc', 'thread', 'done', '500', '--dry-run']) expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Would archive thread')) expect(consoleSpy).toHaveBeenCalledWith(' Thread: Test Thread (500)') @@ -1402,7 +1418,7 @@ describe('thread done', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'thread', 'done', '500', '--dry-run']), + program.parseAsync(['node', 'tdc', 'thread', 'done', '500', '--dry-run']), ).rejects.toThrow('thread not found') expect(client.inbox.archiveThread).not.toHaveBeenCalled() }) diff --git a/src/commands/update/index.ts b/src/commands/update/index.ts index 0e32dbe..0367a34 100644 --- a/src/commands/update/index.ts +++ b/src/commands/update/index.ts @@ -9,7 +9,7 @@ export function registerUpdateCommand(program: Command): void { packageName: packageJson.name, currentVersion: packageJson.version, configPath: getConfigPath(), - changelogCommandName: 'cm changelog', + changelogCommandName: 'tdc changelog', withSpinner, }) } diff --git a/src/commands/update/update.test.ts b/src/commands/update/update.test.ts index 182e419..569256a 100644 --- a/src/commands/update/update.test.ts +++ b/src/commands/update/update.test.ts @@ -49,7 +49,7 @@ describe('update wrapper', () => { packageName: packageJson.name, currentVersion: packageJson.version, configPath: tmpConfigPath, - changelogCommandName: 'cm changelog', + changelogCommandName: 'tdc changelog', withSpinner, }) }) @@ -67,7 +67,7 @@ describe('update wrapper', () => { program.exitOverride() registerUpdateCommand(program) - await program.parseAsync(['node', 'cm', 'update', '--check']) + await program.parseAsync(['node', 'tdc', 'update', '--check']) expect(fetchMock).toHaveBeenCalledTimes(1) const [url] = fetchMock.mock.calls[0] @@ -87,8 +87,8 @@ describe('update wrapper', () => { program.exitOverride() registerUpdateCommand(program) - await expect(program.parseAsync(['node', 'cm', 'update', '--check'])).rejects.toMatchObject( - { code: 'INVALID_UPDATE_CHANNEL' }, - ) + await expect( + program.parseAsync(['node', 'tdc', 'update', '--check']), + ).rejects.toMatchObject({ code: 'INVALID_UPDATE_CHANNEL' }) }) }) diff --git a/src/commands/user.test.ts b/src/commands/user.test.ts index b3ebd41..9c9efef 100644 --- a/src/commands/user.test.ts +++ b/src/commands/user.test.ts @@ -38,12 +38,12 @@ describe('users --workspace conflict', () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'users', 'Doist', '--workspace', 'Other']), + program.parseAsync(['node', 'tdc', 'users', 'Doist', '--workspace', 'Other']), ).rejects.toThrow('Cannot specify workspace both as argument and --workspace flag') }) }) -describeEmptyMachineOutput('cm users empty output', { +describeEmptyMachineOutput('tdc users empty output', { setup: () => { vi.clearAllMocks() apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) @@ -51,7 +51,7 @@ describeEmptyMachineOutput('cm users empty output', { }, run: async (extraArgs) => { const program = createProgram() - await program.parseAsync(['node', 'cm', 'users', ...extraArgs]) + await program.parseAsync(['node', 'tdc', 'users', ...extraArgs]) }, humanMessage: 'No users found.', }) @@ -78,7 +78,7 @@ describe('user --json', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'user', '--json']) + await program.parseAsync(['node', 'tdc', 'user', '--json']) expect(consoleSpy).toHaveBeenCalledTimes(1) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) @@ -96,7 +96,7 @@ describe('user --json', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'cm', 'user', '--json', '--full']) + await program.parseAsync(['node', 'tdc', 'user', '--json', '--full']) expect(consoleSpy).toHaveBeenCalledTimes(1) const jsonOutput = JSON.parse(consoleSpy.mock.calls[0][0]) diff --git a/src/commands/user.ts b/src/commands/user.ts index ec00379..8f02b70 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -88,8 +88,8 @@ export function registerUserCommand(program: Command): void { 'after', ` Examples: - cm user - cm user --json`, + tdc user + tdc user --json`, ) .action(showCurrentUser) @@ -105,8 +105,8 @@ Examples: 'after', ` Examples: - cm users - cm users --search "Jane" --json`, + tdc users + tdc users --search "Jane" --json`, ) .action(listUsers) } diff --git a/src/commands/view.test.ts b/src/commands/view.test.ts index fe88f91..5b972dc 100644 --- a/src/commands/view.test.ts +++ b/src/commands/view.test.ts @@ -37,7 +37,7 @@ function createProgram() { return program } -describe('cm view routing', () => { +describe('tdc view routing', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -47,7 +47,7 @@ describe('cm view routing', () => { await expect( program.parseAsync([ 'node', - 'cm', + 'tdc', 'view', 'https://comms.todoist.com/a/1585/ch/100/t/200', ]), @@ -59,7 +59,7 @@ describe('cm view routing', () => { await expect( program.parseAsync([ 'node', - 'cm', + 'tdc', 'view', 'https://comms.todoist.com/a/1585/ch/100/t/200/c/300', ]), @@ -69,7 +69,7 @@ describe('cm view routing', () => { it('routes conversation URL to conversation view', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'view', 'https://comms.todoist.com/a/1585/msg/400']), + program.parseAsync(['node', 'tdc', 'view', 'https://comms.todoist.com/a/1585/msg/400']), ).rejects.toThrow('ROUTED_TO_CONVERSATION') }) @@ -78,7 +78,7 @@ describe('cm view routing', () => { await expect( program.parseAsync([ 'node', - 'cm', + 'tdc', 'view', 'https://comms.todoist.com/a/1585/msg/400/m/500', ]), @@ -88,14 +88,14 @@ describe('cm view routing', () => { it('throws for unrecognized Comms URL', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'view', 'https://comms.todoist.com/a/1585']), + program.parseAsync(['node', 'tdc', 'view', 'https://comms.todoist.com/a/1585']), ).rejects.toThrow('Not a recognized Comms URL') }) it('throws for non-Comms URL', async () => { const program = createProgram() await expect( - program.parseAsync(['node', 'cm', 'view', 'https://google.com/something']), + program.parseAsync(['node', 'tdc', 'view', 'https://google.com/something']), ).rejects.toThrow('Not a recognized Comms URL') }) }) diff --git a/src/commands/view.ts b/src/commands/view.ts index bc7f914..034478b 100644 --- a/src/commands/view.ts +++ b/src/commands/view.ts @@ -25,7 +25,7 @@ async function runRoutedCommand( proxy.exitOverride() const register = await loadRegister() register(proxy) - await proxy.parseAsync(['node', 'cm', ...argv]) + await proxy.parseAsync(['node', 'tdc', ...argv]) } export function registerViewCommand(program: Command): void { @@ -37,22 +37,22 @@ export function registerViewCommand(program: Command): void { 'after', ` Route mapping: - Message URL → cm msg view - Conversation URL → cm conversation view - Comment URL → cm thread view (comment ID extracted from URL) - Thread URL → cm thread view + Message URL → tdc msg view + Conversation URL → tdc conversation view + Comment URL → tdc thread view (comment ID extracted from URL) + Thread URL → tdc thread view Examples: - cm view https://comms.todoist.com/a/1585/ch/100/t/200 - cm view https://comms.todoist.com/a/1585/ch/100/t/200/c/300 - cm view https://comms.todoist.com/a/1585/msg/400 - cm view https://comms.todoist.com/a/1585/msg/400/m/500 - cm view https://comms.todoist.com/a/1585/msg/400/m/500 --json`, + tdc view https://comms.todoist.com/a/1585/ch/100/t/200 + tdc view https://comms.todoist.com/a/1585/ch/100/t/200/c/300 + tdc view https://comms.todoist.com/a/1585/msg/400 + tdc view https://comms.todoist.com/a/1585/msg/400/m/500 + tdc view https://comms.todoist.com/a/1585/msg/400/m/500 --json`, ) .action(async (url: string) => { const urlHints = [ 'Expected: https://comms.todoist.com/a/{workspaceId}/...', - 'Run: cm view --help for examples', + 'Run: tdc view --help for examples', ] if (!looksLikeCommsAppUrl(url)) { throw new CliError('INVALID_URL', `Not a recognized Comms URL: ${url}`, urlHints) diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index 8d259a0..d121c37 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -54,8 +54,8 @@ export function registerWorkspaceCommand(program: Command): void { 'after', ` Examples: - cm workspaces - cm workspaces --json`, + tdc workspaces + tdc workspaces --json`, ) .action(listWorkspaces) @@ -68,8 +68,8 @@ Examples: 'after', ` Examples: - cm workspace use "My Workspace" - cm workspace use id:1585`, + tdc workspace use "My Workspace" + tdc workspace use id:1585`, ) .action(useWorkspace) } diff --git a/src/index.ts b/src/index.ts index 84a702a..265f962 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,7 +83,7 @@ const commandAliases: Record = { } program - .name('cm') + .name('tdc') .description('Comms CLI') .version(pkg.version) .option('--no-spinner', 'Disable loading animations') @@ -92,7 +92,7 @@ program '--include-private-channels', 'Include joined private channels in output when explicitly needed (env: COMMS_INCLUDE_PRIVATE_CHANNELS)', ) - .option('--accessible', 'Add text labels to color-coded output (also: CM_ACCESSIBLE=1)') + .option('--accessible', 'Add text labels to color-coded output (also: TDC_ACCESSIBLE=1)') .option( '--non-interactive', 'Disable interactive prompts (auto-detected when stdin is not a TTY)', diff --git a/src/lib/api.ts b/src/lib/api.ts index 0befdd5..dfce9a1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -188,7 +188,7 @@ function wrapResult( throw new CliError( 'INSUFFICIENT_SCOPE', 'This action requires permissions your current token does not have.', - ['Run `cm auth login` to re-authenticate with the required scopes'], + ['Run `tdc auth login` to re-authenticate with the required scopes'], ) } throw error diff --git a/src/lib/auth-pages.ts b/src/lib/auth-pages.ts index 04f908d..2bf9d16 100644 --- a/src/lib/auth-pages.ts +++ b/src/lib/auth-pages.ts @@ -398,13 +398,13 @@ export function renderSuccess(): string {
$ - cm + tdc inbox
5 unread threads
$ - cm + tdc compose "Weekly update"
@@ -425,7 +425,7 @@ export function renderSuccess(): string {

Return to your terminal

-

You can close this window. Run cm --help to see available commands.

+

You can close this window. Run tdc --help to see available commands.

@@ -700,7 +700,7 @@ export function renderError(errorMessage: string): string {

Authentication failed

${errorMessage}

-
Try again with cm auth login
+
Try again with tdc auth login
` diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts index c057246..0c6fb59 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -55,7 +55,7 @@ export const READ_ONLY_SCOPES = [ 'notifications:read', ] -const AUTH_HINTS = ['Try again: cm auth login', 'Or set COMMS_API_TOKEN environment variable'] +const AUTH_HINTS = ['Try again: tdc auth login', 'Or set COMMS_API_TOKEN environment variable'] /** * Narrow account shape: only fields that round-trip through the local token @@ -276,7 +276,7 @@ const TOKEN_ENV_VAR = 'COMMS_API_TOKEN' /** * Resolve a `ref` against the local store, returning the canonical account. - * Throws `ACCOUNT_NOT_FOUND` on a miss. Shared between the `cm account ...` + * Throws `ACCOUNT_NOT_FOUND` on a miss. Shared between the `tdc account ...` * commands and `withUserRefAware` so the same hint reaches every caller. */ export async function findAccountInStore( @@ -287,7 +287,7 @@ export async function findAccountInStore( const match = records.find(({ account }) => matchCommsAccount(account, ref)) if (!match) { throw new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`, [ - 'Run: cm account list', + 'Run: tdc account list', ]) } return match.account diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 54a4304..21af2b1 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -43,8 +43,8 @@ export class NoTokenError extends CliError { constructor() { super( 'NO_TOKEN', - `No API token found. Set ${TOKEN_ENV_VAR} or run \`cm auth login\` or \`cm auth token \`.`, - ['Set COMMS_API_TOKEN or run: cm auth login'], + `No API token found. Set ${TOKEN_ENV_VAR} or run \`tdc auth login\` or \`tdc auth token \`.`, + ['Set COMMS_API_TOKEN or run: tdc auth login'], 'info', ) this.name = 'NoTokenError' @@ -58,7 +58,7 @@ export async function getApiToken(): Promise { return snapshot.token } -/** Token + metadata in one round-trip for `cm config view` / `cm doctor`. */ +/** Token + metadata in one round-trip for `tdc config view` / `tdc doctor`. */ export async function probeApiToken(): Promise { const snapshot = await createCommsTokenStore().active() if (!snapshot) throw new NoTokenError() @@ -72,7 +72,7 @@ export async function probeApiToken(): Promise { } } -/** Auth metadata for `cm auth status` and `ensureWriteAllowed`. */ +/** Auth metadata for `tdc auth status` and `ensureWriteAllowed`. */ export async function getAuthMetadata(): Promise { if (process.env[TOKEN_ENV_VAR]) return { authMode: 'unknown', source: 'env' } const config = await getConfig() diff --git a/src/lib/completion.test.ts b/src/lib/completion.test.ts index 2341d7f..6143e9e 100644 --- a/src/lib/completion.test.ts +++ b/src/lib/completion.test.ts @@ -10,7 +10,7 @@ import { function createTestProgram(): Command { const program = new Command() - program.name('cm') + program.name('tdc') const thread = program.command('thread').description('Thread operations') thread.command('view').description('View thread').option('--json', 'Output as JSON') @@ -98,12 +98,12 @@ describe('getCompletions', () => { describe('parseCompLine quoted argument limitation', () => { it('splits quoted multi-word arguments into separate tokens', () => { - const result = parseCompLine('cm thread reply "hello world"') + const result = parseCompLine('tdc thread reply "hello world"') expect(result).toEqual(['thread', 'reply', '"hello', 'world"']) }) it('strips completion-server token', () => { - const result = parseCompLine('cm completion-server thread rep') + const result = parseCompLine('tdc completion-server thread rep') expect(result).toEqual(['thread', 'rep']) }) }) diff --git a/src/lib/completion.ts b/src/lib/completion.ts index 58a9330..e8311c4 100644 --- a/src/lib/completion.ts +++ b/src/lib/completion.ts @@ -32,7 +32,7 @@ export function withCaseInsensitiveChoices(opt: Option, values: string[]): Optio * tabtab exposes shell-provided tokenized words. */ export function parseCompLine(compLine: string): string[] { - const words = compLine.split(/\s+/).slice(1) // remove binary name (cm) + const words = compLine.split(/\s+/).slice(1) // remove binary name (tdc) if (words[0] === 'completion-server') words.shift() return words } diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index bc22b6a..5f1b763 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -181,7 +181,7 @@ describe('readConfigStrict wrapper', () => { await expect(readConfigStrict()).rejects.toMatchObject({ code: 'CONFIG_READ_FAILED', message: expect.stringContaining('EACCES: permission denied'), - hints: ['Check file permissions, or run `cm doctor` to diagnose'], + hints: ['Check file permissions, or run `tdc doctor` to diagnose'], }) }) @@ -194,7 +194,7 @@ describe('readConfigStrict wrapper', () => { code: 'CONFIG_INVALID_JSON', message: expect.stringContaining('Unexpected token'), hints: [ - 'Fix the JSON by hand, or delete the file and re-authenticate with `cm auth login`', + 'Fix the JSON by hand, or delete the file and re-authenticate with `tdc auth login`', ], }) }) @@ -208,7 +208,7 @@ describe('readConfigStrict wrapper', () => { code: 'CONFIG_INVALID_SHAPE', message: expect.stringContaining('got array'), hints: [ - 'Fix the JSON by hand, or delete the file and re-authenticate with `cm auth login`', + 'Fix the JSON by hand, or delete the file and re-authenticate with `tdc auth login`', ], }) }) diff --git a/src/lib/config.ts b/src/lib/config.ts index b7ebdba..c65318a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -126,14 +126,14 @@ export async function readConfigStrict(): Promise { throw new CliError( 'CONFIG_READ_FAILED', `Could not read config file ${path}: ${result.error.message}`, - ['Check file permissions, or run `cm doctor` to diagnose'], + ['Check file permissions, or run `tdc doctor` to diagnose'], ) case 'invalid-json': throw new CliError( 'CONFIG_INVALID_JSON', `Config file at ${path} is not valid JSON: ${result.error.message}`, [ - 'Fix the JSON by hand, or delete the file and re-authenticate with `cm auth login`', + 'Fix the JSON by hand, or delete the file and re-authenticate with `tdc auth login`', ], ) case 'invalid-shape': @@ -141,7 +141,7 @@ export async function readConfigStrict(): Promise { 'CONFIG_INVALID_SHAPE', `Config file at ${path} must contain a JSON object (got ${result.actual})`, [ - 'Fix the JSON by hand, or delete the file and re-authenticate with `cm auth login`', + 'Fix the JSON by hand, or delete the file and re-authenticate with `tdc auth login`', ], ) } @@ -154,7 +154,7 @@ export async function setConfig(config: Config): Promise { /** * Atomic partial-write wrapper around cli-core's `updateConfig`. Preserves - * cli-core's read-merge-write atomicity so two concurrent `cm` processes + * cli-core's read-merge-write atomicity so two concurrent `tdc` processes * can't lose each other's updates. */ export async function updateConfig(updates: Partial): Promise { diff --git a/src/lib/global-args.test.ts b/src/lib/global-args.test.ts index dfa0534..da1e322 100644 --- a/src/lib/global-args.test.ts +++ b/src/lib/global-args.test.ts @@ -60,25 +60,25 @@ describe('parseGlobalArgs', () => { describe('--progress-jsonl', () => { it('detects --progress-jsonl without path', () => { - const result = parseGlobalArgs(['node', 'cm', '--progress-jsonl']) + const result = parseGlobalArgs(['node', 'tdc', '--progress-jsonl']) expect(result.progressJsonl).toBe(true) expect(result.progressJsonlPath).toBeUndefined() }) it('detects --progress-jsonl=path', () => { - const result = parseGlobalArgs(['node', 'cm', '--progress-jsonl=/tmp/out.jsonl']) + const result = parseGlobalArgs(['node', 'tdc', '--progress-jsonl=/tmp/out.jsonl']) expect(result.progressJsonl).toBe('/tmp/out.jsonl') expect(result.progressJsonlPath).toBe('/tmp/out.jsonl') }) it('detects --progress-jsonl path as separate arg (Comms re-adds the space form cli-core drops)', () => { - const result = parseGlobalArgs(['node', 'cm', '--progress-jsonl', '/tmp/out.jsonl']) + const result = parseGlobalArgs(['node', 'tdc', '--progress-jsonl', '/tmp/out.jsonl']) expect(result.progressJsonl).toBe('/tmp/out.jsonl') expect(result.progressJsonlPath).toBe('/tmp/out.jsonl') }) it('does not treat next flag as path', () => { - const result = parseGlobalArgs(['node', 'cm', '--progress-jsonl', '--json']) + const result = parseGlobalArgs(['node', 'tdc', '--progress-jsonl', '--json']) expect(result.progressJsonl).toBe(true) expect(result.progressJsonlPath).toBeUndefined() }) @@ -89,7 +89,7 @@ describe('parseGlobalArgs', () => { it('=path then space form: space form wins', () => { const result = parseGlobalArgs([ 'node', - 'cm', + 'tdc', '--progress-jsonl=/tmp/first', '--progress-jsonl', '/tmp/second', @@ -101,7 +101,7 @@ describe('parseGlobalArgs', () => { it('space form then =path: =path wins', () => { const result = parseGlobalArgs([ 'node', - 'cm', + 'tdc', '--progress-jsonl', '/tmp/first', '--progress-jsonl=/tmp/second', @@ -113,7 +113,7 @@ describe('parseGlobalArgs', () => { it('path then bare: bare reverts to true (no path)', () => { const result = parseGlobalArgs([ 'node', - 'cm', + 'tdc', '--progress-jsonl', '/tmp/first', '--progress-jsonl', @@ -125,7 +125,7 @@ describe('parseGlobalArgs', () => { it('repeated =path forms: last wins', () => { const result = parseGlobalArgs([ 'node', - 'cm', + 'tdc', '--progress-jsonl=/tmp/first', '--progress-jsonl=/tmp/second', ]) @@ -141,7 +141,7 @@ describe('cached singleton', () => { beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'cm'] + process.argv = ['node', 'tdc'] }) afterEach(() => { @@ -150,16 +150,16 @@ describe('cached singleton', () => { }) it('returns fresh results after resetGlobalArgs()', () => { - process.argv = ['node', 'cm'] + process.argv = ['node', 'tdc'] expect(isProgressJsonlEnabled()).toBe(false) resetGlobalArgs() - process.argv = ['node', 'cm', '--progress-jsonl'] + process.argv = ['node', 'tdc', '--progress-jsonl'] expect(isProgressJsonlEnabled()).toBe(true) }) it('exposes the resolved path via getProgressJsonlPath()', () => { - process.argv = ['node', 'cm', '--progress-jsonl', '/tmp/out.jsonl'] + process.argv = ['node', 'tdc', '--progress-jsonl', '/tmp/out.jsonl'] expect(getProgressJsonlPath()).toBe('/tmp/out.jsonl') }) }) @@ -169,13 +169,13 @@ describe('isAccessible', () => { beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'cm'] - delete process.env.CM_ACCESSIBLE + process.argv = ['node', 'tdc'] + delete process.env.TDC_ACCESSIBLE }) afterEach(() => { process.argv = originalArgv - delete process.env.CM_ACCESSIBLE + delete process.env.TDC_ACCESSIBLE resetGlobalArgs() }) @@ -183,20 +183,20 @@ describe('isAccessible', () => { expect(isAccessible()).toBe(false) }) - it('returns true when CM_ACCESSIBLE=1', () => { - process.env.CM_ACCESSIBLE = '1' + it('returns true when TDC_ACCESSIBLE=1', () => { + process.env.TDC_ACCESSIBLE = '1' expect(isAccessible()).toBe(true) }) - it('returns false when CM_ACCESSIBLE is set to other values', () => { - process.env.CM_ACCESSIBLE = '0' + it('returns false when TDC_ACCESSIBLE is set to other values', () => { + process.env.TDC_ACCESSIBLE = '0' expect(isAccessible()).toBe(false) - process.env.CM_ACCESSIBLE = 'true' + process.env.TDC_ACCESSIBLE = 'true' expect(isAccessible()).toBe(false) }) it('returns true when --accessible is in argv', () => { - process.argv = ['node', 'cm', '--accessible'] + process.argv = ['node', 'tdc', '--accessible'] resetGlobalArgs() expect(isAccessible()).toBe(true) }) @@ -209,7 +209,7 @@ describe('isNonInteractive', () => { beforeEach(() => { originalIsTTY = process.stdin.isTTY resetGlobalArgs() - process.argv = ['node', 'cm'] + process.argv = ['node', 'tdc'] }) afterEach(() => { @@ -242,7 +242,7 @@ describe('isNonInteractive', () => { value: true, configurable: true, }) - process.argv = ['node', 'cm', '--non-interactive'] + process.argv = ['node', 'tdc', '--non-interactive'] resetGlobalArgs() expect(isNonInteractive()).toBe(true) }) @@ -252,13 +252,13 @@ describe('isNonInteractive', () => { value: undefined, configurable: true, }) - process.argv = ['node', 'cm', '--interactive'] + process.argv = ['node', 'tdc', '--interactive'] resetGlobalArgs() expect(isNonInteractive()).toBe(false) }) it('--interactive overrides --non-interactive', () => { - process.argv = ['node', 'cm', '--non-interactive', '--interactive'] + process.argv = ['node', 'tdc', '--non-interactive', '--interactive'] resetGlobalArgs() expect(isNonInteractive()).toBe(false) }) @@ -270,7 +270,7 @@ describe('includePrivateChannels', () => { beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'cm'] + process.argv = ['node', 'tdc'] delete process.env.COMMS_INCLUDE_PRIVATE_CHANNELS }) @@ -289,7 +289,7 @@ describe('includePrivateChannels', () => { }) it('returns true when --include-private-channels is in argv', () => { - process.argv = ['node', 'cm', '--include-private-channels'] + process.argv = ['node', 'tdc', '--include-private-channels'] resetGlobalArgs() expect(includePrivateChannels()).toBe(true) }) @@ -317,14 +317,14 @@ describe('shouldDisableSpinner', () => { beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'cm'] - delete process.env.CM_SPINNER + process.argv = ['node', 'tdc'] + delete process.env.TDC_SPINNER delete process.env.CI }) afterEach(() => { process.argv = originalArgv - delete process.env.CM_SPINNER + delete process.env.TDC_SPINNER delete process.env.CI resetGlobalArgs() }) @@ -333,8 +333,8 @@ describe('shouldDisableSpinner', () => { expect(shouldDisableSpinner()).toBe(false) }) - it('returns true when CM_SPINNER=false', () => { - process.env.CM_SPINNER = 'false' + it('returns true when TDC_SPINNER=false', () => { + process.env.TDC_SPINNER = 'false' expect(shouldDisableSpinner()).toBe(true) }) @@ -352,11 +352,11 @@ describe('shouldDisableSpinner', () => { }) it.each([ - ['--json', ['node', 'cm', '--json']], - ['--ndjson', ['node', 'cm', '--ndjson']], - ['--no-spinner', ['node', 'cm', '--no-spinner']], - ['--progress-jsonl', ['node', 'cm', '--progress-jsonl']], - ['--non-interactive', ['node', 'cm', '--non-interactive']], + ['--json', ['node', 'tdc', '--json']], + ['--ndjson', ['node', 'tdc', '--ndjson']], + ['--no-spinner', ['node', 'tdc', '--no-spinner']], + ['--progress-jsonl', ['node', 'tdc', '--progress-jsonl']], + ['--non-interactive', ['node', 'tdc', '--non-interactive']], ])('returns true with %s flag', (_flag, argv) => { process.argv = argv resetGlobalArgs() diff --git a/src/lib/global-args.ts b/src/lib/global-args.ts index 9f6d6c7..2867431 100644 --- a/src/lib/global-args.ts +++ b/src/lib/global-args.ts @@ -60,7 +60,7 @@ type CommsLocalFlags = { * `=path`, space-separated ``). `false` = absent, `true` = bare, * string = path. Comms parses this locally — and ignores cli-core's * `progressJsonl` field — so that "last occurrence wins" stays correct - * when the forms are mixed (`cm --progress-jsonl=/a --progress-jsonl /b` + * when the forms are mixed (`tdc --progress-jsonl=/a --progress-jsonl /b` * → `/b`). cli-core deliberately drops the space form cross-CLI because * it can swallow positionals; comms re-adds it because the flag is * global, not subcommand-attached. @@ -148,7 +148,7 @@ export function isNdjsonMode(): boolean { return store.get().ndjson } -/** Pre-subcommand `cm --user ` (see `stripUserFlag` in `src/index.ts`). */ +/** Pre-subcommand `tdc --user ` (see `stripUserFlag` in `src/index.ts`). */ export function getRequestedUserRef(): string | undefined { return store.get().user } @@ -177,12 +177,12 @@ export function getProgressJsonlPath(): string | undefined { } export const isAccessible = createAccessibleGate({ - envVar: 'CM_ACCESSIBLE', + envVar: 'TDC_ACCESSIBLE', getArgs: store.get, }) export const shouldDisableSpinner = createSpinnerGate({ - envVar: 'CM_SPINNER', + envVar: 'TDC_SPINNER', getArgs: store.get, extraTriggers: () => store.get().nonInteractive, }) diff --git a/src/lib/input.test.ts b/src/lib/input.test.ts index 8a32c53..26d8962 100644 --- a/src/lib/input.test.ts +++ b/src/lib/input.test.ts @@ -9,7 +9,7 @@ describe('isNonInteractive', () => { beforeEach(() => { originalIsTTY = process.stdin.isTTY resetGlobalArgs() - process.argv = ['node', 'cm', 'thread', 'create', '100', 'Title'] + process.argv = ['node', 'tdc', 'thread', 'create', '100', 'Title'] }) afterEach(() => { @@ -75,7 +75,7 @@ describe('openEditor', () => { beforeEach(() => { originalIsTTY = process.stdin.isTTY resetGlobalArgs() - process.argv = ['node', 'cm'] + process.argv = ['node', 'tdc'] }) afterEach(() => { diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts index dcf888e..220674e 100644 --- a/src/lib/output.test.ts +++ b/src/lib/output.test.ts @@ -10,11 +10,11 @@ describe('isAccessible', () => { beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'cm'] + process.argv = ['node', 'tdc'] }) afterEach(() => { - delete process.env.CM_ACCESSIBLE + delete process.env.TDC_ACCESSIBLE process.argv = originalArgv resetGlobalArgs() }) @@ -23,20 +23,20 @@ describe('isAccessible', () => { expect(isAccessible()).toBe(false) }) - it('returns true when CM_ACCESSIBLE=1', () => { - process.env.CM_ACCESSIBLE = '1' + it('returns true when TDC_ACCESSIBLE=1', () => { + process.env.TDC_ACCESSIBLE = '1' expect(isAccessible()).toBe(true) }) - it('returns false when CM_ACCESSIBLE is set to other values', () => { - process.env.CM_ACCESSIBLE = '0' + it('returns false when TDC_ACCESSIBLE is set to other values', () => { + process.env.TDC_ACCESSIBLE = '0' expect(isAccessible()).toBe(false) - process.env.CM_ACCESSIBLE = 'true' + process.env.TDC_ACCESSIBLE = 'true' expect(isAccessible()).toBe(false) }) it('returns true when --accessible is in argv', () => { - process.argv = ['node', 'cm', '--accessible'] + process.argv = ['node', 'tdc', '--accessible'] resetGlobalArgs() expect(isAccessible()).toBe(true) }) diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 792f05e..1d6c94d 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -2,7 +2,7 @@ import { getAuthMetadata } from './auth.js' import { CliError } from './errors.js' export const READ_ONLY_ERROR_MESSAGE = - 'This CLI is authenticated in read-only mode. Re-run `cm auth login` without --read-only to enable write operations.' + 'This CLI is authenticated in read-only mode. Re-run `tdc auth login` without --read-only to enable write operations.' /** * Known read-only API method paths. Any method not in this set is assumed to be mutating. @@ -39,7 +39,7 @@ export async function ensureWriteAllowed(): Promise { const metadata = await getAuthMetadata() if (metadata.authMode === 'read-only') { throw new CliError('READ_ONLY', READ_ONLY_ERROR_MESSAGE, [ - 'Re-run: cm auth login (without --read-only)', + 'Re-run: tdc auth login (without --read-only)', ]) } } diff --git a/src/lib/progress.test.ts b/src/lib/progress.test.ts index a45d333..02f055c 100644 --- a/src/lib/progress.test.ts +++ b/src/lib/progress.test.ts @@ -57,20 +57,20 @@ describe('ProgressTracker', () => { describe('initialization and enabling', () => { it.each([ - ['disabled by default', ['node', 'cm', 'threads'], false], + ['disabled by default', ['node', 'tdc', 'threads'], false], [ 'enabled with --progress-jsonl flag', - ['node', 'cm', 'threads', '--progress-jsonl'], + ['node', 'tdc', 'threads', '--progress-jsonl'], true, ], [ 'enabled with --progress-jsonl=path flag', - ['node', 'cm', 'threads', '--progress-jsonl=/tmp/progress.jsonl'], + ['node', 'tdc', 'threads', '--progress-jsonl=/tmp/progress.jsonl'], true, ], [ 'enabled with --progress-jsonl path as separate arg', - ['node', 'cm', 'threads', '--progress-jsonl', '/tmp/progress.jsonl'], + ['node', 'tdc', 'threads', '--progress-jsonl', '/tmp/progress.jsonl'], true, ], ])('should be %s', (_description, argv, expectedEnabled) => { @@ -82,7 +82,7 @@ describe('ProgressTracker', () => { describe('output destinations', () => { it('should output to stderr by default', () => { - process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] + process.argv = ['node', 'tdc', 'threads', '--progress-jsonl'] const tracker = new ProgressTracker() tracker.emit({ type: 'start', command: 'threads' }) @@ -93,7 +93,7 @@ describe('ProgressTracker', () => { }) it('should create file when path is provided with equals', () => { - process.argv = ['node', 'cm', 'threads', '--progress-jsonl=/tmp/progress.jsonl'] + process.argv = ['node', 'tdc', 'threads', '--progress-jsonl=/tmp/progress.jsonl'] const tracker = new ProgressTracker() expect(fs.createWriteStream).toHaveBeenCalledWith('/tmp/progress.jsonl', { flags: 'a' }) @@ -103,14 +103,14 @@ describe('ProgressTracker', () => { }) it('should create file when path is provided as separate arg', () => { - process.argv = ['node', 'cm', 'threads', '--progress-jsonl', '/tmp/progress.jsonl'] + process.argv = ['node', 'tdc', 'threads', '--progress-jsonl', '/tmp/progress.jsonl'] const _tracker = new ProgressTracker() expect(fs.createWriteStream).toHaveBeenCalledWith('/tmp/progress.jsonl', { flags: 'a' }) }) it('should fall back to stderr if file creation fails', () => { - process.argv = ['node', 'cm', 'threads', '--progress-jsonl=/invalid/path'] + process.argv = ['node', 'tdc', 'threads', '--progress-jsonl=/invalid/path'] vi.mocked(fs.createWriteStream).mockImplementation(() => { throw new Error('Permission denied') }) @@ -133,7 +133,7 @@ describe('ProgressTracker', () => { let tracker: ProgressTracker beforeEach(() => { - process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] + process.argv = ['node', 'tdc', 'threads', '--progress-jsonl'] tracker = new ProgressTracker() }) @@ -210,7 +210,7 @@ describe('ProgressTracker', () => { it('should not emit events when disabled', () => { resetProgressTracker() resetGlobalArgs() - process.argv = ['node', 'cm', 'threads'] // No --progress-jsonl flag + process.argv = ['node', 'tdc', 'threads'] // No --progress-jsonl flag const disabledTracker = new ProgressTracker() disabledTracker.emitStart('threads') @@ -244,7 +244,7 @@ describe('ProgressTracker', () => { describe('cleanup', () => { it('should close file stream when calling close()', () => { - process.argv = ['node', 'cm', 'threads', '--progress-jsonl=/tmp/progress.jsonl'] + process.argv = ['node', 'tdc', 'threads', '--progress-jsonl=/tmp/progress.jsonl'] const tracker = new ProgressTracker() tracker.close() @@ -254,7 +254,7 @@ describe('ProgressTracker', () => { }) it('should handle close() when using stderr', () => { - process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] + process.argv = ['node', 'tdc', 'threads', '--progress-jsonl'] const tracker = new ProgressTracker() // Should not throw @@ -281,7 +281,7 @@ describe('global progress tracker', () => { }) it('should return singleton instance', () => { - process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] + process.argv = ['node', 'tdc', 'threads', '--progress-jsonl'] const tracker1 = getProgressTracker() const tracker2 = getProgressTracker() @@ -290,7 +290,7 @@ describe('global progress tracker', () => { }) it('should create new instance after reset', () => { - process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] + process.argv = ['node', 'tdc', 'threads', '--progress-jsonl'] const tracker1 = getProgressTracker() resetProgressTracker() @@ -301,14 +301,14 @@ describe('global progress tracker', () => { it('should respect argv changes between instances', () => { // First instance - disabled - process.argv = ['node', 'cm', 'threads'] + process.argv = ['node', 'tdc', 'threads'] const tracker1 = getProgressTracker() expect(tracker1.isEnabled()).toBe(false) // Reset and create new instance with flag resetProgressTracker() resetGlobalArgs() - process.argv = ['node', 'cm', 'threads', '--progress-jsonl'] + process.argv = ['node', 'tdc', 'threads', '--progress-jsonl'] const tracker2 = getProgressTracker() expect(tracker2.isEnabled()).toBe(true) }) @@ -341,10 +341,10 @@ describe('edge cases and integration', () => { }) it.each([ - ['flag in middle of arguments', ['node', 'cm', '--progress-jsonl', 'threads', '--json']], - ['flag at end of arguments', ['node', 'cm', 'threads', '--json', '--progress-jsonl']], - ['flag with empty path argument', ['node', 'cm', 'threads', '--progress-jsonl', '']], - ['flag followed by another flag', ['node', 'cm', 'threads', '--progress-jsonl', '--json']], + ['flag in middle of arguments', ['node', 'tdc', '--progress-jsonl', 'threads', '--json']], + ['flag at end of arguments', ['node', 'tdc', 'threads', '--json', '--progress-jsonl']], + ['flag with empty path argument', ['node', 'tdc', 'threads', '--progress-jsonl', '']], + ['flag followed by another flag', ['node', 'tdc', 'threads', '--progress-jsonl', '--json']], ])('should handle %s', (_description, argv) => { process.argv = argv const tracker = new ProgressTracker() @@ -354,7 +354,7 @@ describe('edge cases and integration', () => { it('should handle multiple progress-jsonl flags (last one wins)', () => { process.argv = [ 'node', - 'cm', + 'tdc', '--progress-jsonl=/tmp/first', '--progress-jsonl=/tmp/second', 'threads', diff --git a/src/lib/public-channels.test.ts b/src/lib/public-channels.test.ts index f95e8ea..3e35676 100644 --- a/src/lib/public-channels.test.ts +++ b/src/lib/public-channels.test.ts @@ -30,7 +30,7 @@ describe('includePrivateChannels', () => { beforeEach(() => { resetGlobalArgs() - process.argv = ['node', 'cm'] + process.argv = ['node', 'tdc'] delete process.env.COMMS_INCLUDE_PRIVATE_CHANNELS }) @@ -49,7 +49,7 @@ describe('includePrivateChannels', () => { }) it('returns true when --include-private-channels is in argv', () => { - process.argv = ['node', 'cm', 'channels', '--include-private-channels'] + process.argv = ['node', 'tdc', 'channels', '--include-private-channels'] resetGlobalArgs() expect(includePrivateChannels()).toBe(true) }) @@ -126,7 +126,7 @@ describe('assertChannelIsPublic', () => { beforeEach(() => { clearPublicChannelCache() resetGlobalArgs() - process.argv = ['node', 'cm'] + process.argv = ['node', 'tdc'] delete process.env.COMMS_INCLUDE_PRIVATE_CHANNELS }) @@ -158,7 +158,7 @@ describe('assertChannelIsPublic', () => { }) it('allows private channels when --include-private-channels is set', async () => { - process.argv = ['node', 'cm', '--include-private-channels'] + process.argv = ['node', 'tdc', '--include-private-channels'] resetGlobalArgs() await expect(assertChannelIsPublic(999, 100)).resolves.toBeUndefined() }) diff --git a/src/lib/refs.ts b/src/lib/refs.ts index cf77504..6cf18d5 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -148,7 +148,7 @@ export async function resolveWorkspaceRef(ref: string): Promise { const workspace = workspaces.find((w) => w.id === parsed.id) if (!workspace) { throw new CliError('WORKSPACE_NOT_FOUND', `Workspace with ID ${parsed.id} not found`, [ - 'Run: cm workspaces to list available workspaces', + 'Run: tdc workspaces to list available workspaces', ]) } return workspace @@ -160,7 +160,7 @@ export async function resolveWorkspaceRef(ref: string): Promise { throw new CliError( 'WORKSPACE_NOT_FOUND', `Workspace with ID ${parsed.parsed.workspaceId} not found`, - ['Run: cm workspaces to list available workspaces'], + ['Run: tdc workspaces to list available workspaces'], ) } return workspace @@ -171,12 +171,12 @@ export async function resolveWorkspaceRef(ref: string): Promise { ambiguousCode: 'AMBIGUOUS_WORKSPACE', notFoundCode: 'WORKSPACE_NOT_FOUND', ref, - listHint: 'Run: cm workspaces to list available workspaces', + listHint: 'Run: tdc workspaces to list available workspaces', }) } throw new CliError('WORKSPACE_NOT_FOUND', `Workspace "${ref}" not found`, [ - 'Run: cm workspaces to list available workspaces', + 'Run: tdc workspaces to list available workspaces', ]) } @@ -235,12 +235,12 @@ export async function resolveChannelRef(ref: string, workspaceId: number): Promi ambiguousCode: 'AMBIGUOUS_CHANNEL', notFoundCode: 'CHANNEL_NOT_FOUND', ref, - listHint: 'Run: cm channels to list available channels', + listHint: 'Run: tdc channels to list available channels', }) } throw new CliError('CHANNEL_NOT_FOUND', `Channel "${ref}" not found`, [ - 'Run: cm channels to list available channels', + 'Run: tdc channels to list available channels', ]) } @@ -378,7 +378,7 @@ export async function resolveGroupRef(ref: string, workspaceId: number): Promise } catch (error) { if (error instanceof CliError) throw error throw new CliError('GROUP_NOT_FOUND', `Group with ID ${parsed.id} not found`, [ - 'Run: cm groups to list available groups', + 'Run: tdc groups to list available groups', ]) } } @@ -389,12 +389,12 @@ export async function resolveGroupRef(ref: string, workspaceId: number): Promise ambiguousCode: 'AMBIGUOUS_GROUP', notFoundCode: 'GROUP_NOT_FOUND', ref, - listHint: 'Run: cm groups to list available groups', + listHint: 'Run: tdc groups to list available groups', }) } throw new CliError('GROUP_NOT_FOUND', `Group "${ref}" not found`, [ - 'Run: cm groups to list available groups', + 'Run: tdc groups to list available groups', ]) } @@ -419,7 +419,7 @@ export async function resolveUserRefs(refs: string, workspaceId: number): Promis if (matches.length === 0) { throw new CliError('USER_NOT_FOUND', `No user found matching "${ref}"`, [ - 'Run: cm users to list workspace members', + 'Run: tdc users to list workspace members', ]) } diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 3a73ce0..2ecf339 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -11,116 +11,116 @@ export const SKILL_LICENSE = 'MIT' export const SKILL_VERSION = packageJson.version -export const SKILL_CONTENT = `# Comms CLI (cm) +export const SKILL_CONTENT = `# Comms CLI (tdc) -Access Comms messaging via the \`cm\` CLI. Use when the user asks about their Comms workspaces, threads, messages, or wants to interact with Comms in any way. +Access Comms messaging via the \`tdc\` CLI. Use when the user asks about their Comms workspaces, threads, messages, or wants to interact with Comms in any way. ## Setup \`\`\`bash -cm auth login # OAuth login (opens browser, read-write) -cm auth login --read-only # OAuth login with read-only scope -cm auth login --callback-port # Override the local OAuth callback port (default 8766) -cm auth login --json # Emit a JSON envelope for scripted / agent use -cm auth login --ndjson # Emit an NDJSON envelope for scripted / agent use -cm auth token # Save API token manually (prompts securely; scope unknown, assumed write-capable) -cm auth status # Verify authentication + show mode -cm auth status --json # Full status payload as JSON (--ndjson also supported) -cm auth status --user # Target a specific stored account (id, id:, or display name) -cm --user auth # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it -cm auth logout # Remove saved token and auth metadata -cm auth logout --json # Emits \`{"ok": true}\` (--ndjson is silent) -cm auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND -cm auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set) -cm auth token view --user # Print the saved token for a specific stored account -cm account [list|current|use |remove ] # Manage stored accounts; all support --json/--ndjson +tdc auth login # OAuth login (opens browser, read-write) +tdc auth login --read-only # OAuth login with read-only scope +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 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) +tdc --user auth # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it +tdc auth logout # Remove saved token and auth metadata +tdc auth logout --json # Emits \`{"ok": true}\` (--ndjson is silent) +tdc auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND +tdc auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set) +tdc auth token view --user # Print the saved token for a specific stored account +tdc account [list|current|use |remove ] # Manage stored accounts; all support --json/--ndjson # current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"token-only"} -cm auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set -cm workspaces # List available workspaces -cm workspace use # Set current workspace -cm completion install # Install shell completions -cm config view # Show the current CLI configuration file (token masked) -cm config set # Set a user preference (e.g. unarchive-new-threads true) -cm doctor # Diagnose CLI setup and environment issues -cm update # Update CLI to latest version -cm changelog # Show recent changelog entries +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 workspace use # Set current workspace +tdc completion install # Install shell completions +tdc config view # Show the current CLI configuration file (token masked) +tdc config set # Set a user preference (e.g. unarchive-new-threads true) +tdc doctor # Diagnose CLI setup and environment issues +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, \`cm\` warns and falls back to \`~/.config/comms-cli/config.json\`. \`COMMS_API_TOKEN\` always takes priority over the stored token. +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 (\`cm 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 \`cm auth token\`) are treated as unknown scope and assumed write-capable. +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. ## View by URL \`\`\`bash -cm view # View any Comms entity by URL +tdc view # View any Comms entity by URL \`\`\` Routes automatically based on URL structure: -- Message URL → \`cm msg view\` -- Conversation URL → \`cm conversation view\` -- Thread+comment URL → \`cm thread view\` (comment ID extracted from URL) -- Thread URL → \`cm thread view\` +- Message URL → \`tdc msg view\` +- Conversation URL → \`tdc conversation view\` +- Thread+comment URL → \`tdc thread view\` (comment ID extracted from URL) +- Thread URL → \`tdc thread view\` All target command flags pass through (e.g. \`--json\`, \`--raw\`, \`--full\`). ## Inbox \`\`\`bash -cm inbox # Show inbox threads -cm inbox --unread # Only unread threads -cm inbox --archive-filter all # Show active + done threads -cm inbox --archive-filter archived # Show only done threads -cm inbox --channel # Filter by channel name (fuzzy) -cm inbox --since # Filter by date (ISO format) -cm inbox --limit # Max items (default: 50) +tdc inbox # Show inbox threads +tdc inbox --unread # Only unread threads +tdc inbox --archive-filter all # Show active + done threads +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) \`\`\` ## Threads \`\`\`bash -cm thread # View thread (shorthand for view) -cm thread view # View thread with comments -cm thread view --comment # View a specific comment -cm thread view # Comment ID extracted from URL -cm thread view --unread # Show only unread comments -cm thread view --context 3 # Include 3 read comments before unread -cm thread view --limit 20 # Limit number of comments -cm thread view --since # Comments newer than date -cm thread view --raw # Show raw markdown -cm thread create "Title" "content" # Create a new thread -cm thread create "Title" "content" --json # Create and return as JSON -cm thread create "Title" "content" --json --full # Include all thread fields -cm thread create "Title" "content" --notify 123,456 # Notify specific users -cm thread create "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive) -cm thread create "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true -cm thread create "Title" "content" --dry-run # Preview without posting -cm thread reply "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) -cm thread reply "content" --notify EVERYONE # Notify all workspace members -cm thread reply "content" --notify 123,id:456 # Notify specific user IDs -cm thread reply "content" --json # Post and return comment as JSON -cm thread reply "content" --json --full # Include all comment fields -cm thread reply "content" --close # Reply and close the thread -cm thread reply "content" --reopen # Reply and reopen a closed thread -cm thread done # Archive thread (mark done) -cm thread done --json # Archive and return status as JSON -cm thread mute # Mute thread for 60 minutes (default) -cm thread mute --minutes 480 # Mute for custom duration -cm thread mute --json # Mute and return { id, mutedUntil } as JSON -cm thread mute --json --full # Mute and return full thread as JSON -cm thread unmute # Unmute a muted thread -cm thread unmute --json # Unmute and return { id, mutedUntil } as JSON -cm thread delete # Preview thread deletion (requires --yes to execute) -cm thread delete --yes # Permanently delete a thread -cm thread delete --yes --json # Delete and return status as JSON -cm thread rename "New title" # Rename a thread (change its title) -cm thread rename "New title" --json # Rename and return { id, title } as JSON -cm thread rename "New title" --json --full # Rename and return full thread as JSON -cm thread update "New body" # Update a thread's body (the first post) -echo "New body" | cm thread update # Update body from stdin -cm thread update "New body" --dry-run # Preview without updating -cm thread update "New body" --json # Update and return { id, content } as JSON -cm thread update "New body" --json --full # Update and return full thread as JSON +tdc thread # View thread (shorthand for view) +tdc thread view # View thread with comments +tdc thread view --comment # View a specific comment +tdc thread view # Comment ID extracted from URL +tdc thread view --unread # Show only unread comments +tdc thread view --context 3 # Include 3 read comments before unread +tdc thread view --limit 20 # Limit number of comments +tdc thread view --since # Comments newer than date +tdc thread view --raw # Show raw markdown +tdc thread create "Title" "content" # Create a new thread +tdc thread create "Title" "content" --json # Create and return as JSON +tdc thread create "Title" "content" --json --full # Include all thread fields +tdc thread create "Title" "content" --notify 123,456 # Notify specific users +tdc thread create "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive) +tdc thread create "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true +tdc thread create "Title" "content" --dry-run # Preview without posting +tdc thread reply "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) +tdc thread reply "content" --notify EVERYONE # Notify all workspace members +tdc thread reply "content" --notify 123,id:456 # Notify specific user IDs +tdc thread reply "content" --json # Post and return comment as JSON +tdc thread reply "content" --json --full # Include all comment fields +tdc thread reply "content" --close # Reply and close the thread +tdc thread reply "content" --reopen # Reply and reopen a closed thread +tdc thread done # Archive thread (mark done) +tdc thread done --json # Archive and return status as JSON +tdc thread mute # Mute thread for 60 minutes (default) +tdc thread mute --minutes 480 # Mute for custom duration +tdc thread mute --json # Mute and return { id, mutedUntil } as JSON +tdc thread mute --json --full # Mute and return full thread as JSON +tdc thread unmute # Unmute a muted thread +tdc thread unmute --json # Unmute and return { id, mutedUntil } as JSON +tdc thread delete # Preview thread deletion (requires --yes to execute) +tdc thread delete --yes # Permanently delete a thread +tdc thread delete --yes --json # Delete and return status as JSON +tdc thread rename "New title" # Rename a thread (change its title) +tdc thread rename "New title" --json # Rename and return { id, title } as JSON +tdc thread rename "New title" --json --full # Rename and return full thread as JSON +tdc thread update "New body" # Update a thread's body (the first post) +echo "New body" | tdc thread update # Update body from stdin +tdc thread update "New body" --dry-run # Preview without updating +tdc thread update "New body" --json # Update and return { id, content } as JSON +tdc thread update "New body" --json --full # Update and return full thread as JSON \`\`\` Default \`--notify\` for reply is EVERYONE_IN_THREAD, which may notify more people than intended. Before posting, confirm with the user whether specific people should be notified instead (via \`--notify \`). Options: EVERYONE, EVERYONE_IN_THREAD, or comma-separated ID refs. @@ -130,141 +130,141 @@ Default \`--notify\` for reply is EVERYONE_IN_THREAD, which may notify more peop ## Thread Comments \`\`\`bash -cm comment # View a comment (shorthand for view) -cm comment view # View a single thread comment -cm comment view --raw # Show raw markdown -cm comment view --json # Output as JSON -cm comment view --ndjson # Output as newline-delimited JSON -cm comment view --json --full # Include all fields in JSON output -cm comment update "new content" # Update a thread comment -cm comment update "content" --json # Update and return updated comment as JSON -cm comment update "content" --json --full # Include all comment fields -cm comment delete # Delete a thread comment -cm comment delete --json # Delete and return status as JSON +tdc comment # View a comment (shorthand for view) +tdc comment view # View a single thread comment +tdc comment view --raw # Show raw markdown +tdc comment view --json # Output as JSON +tdc comment view --ndjson # Output as newline-delimited JSON +tdc comment view --json --full # Include all fields in JSON output +tdc comment update "new content" # Update a thread comment +tdc comment update "content" --json # Update and return updated comment as JSON +tdc comment update "content" --json --full # Include all comment fields +tdc comment delete # Delete a thread comment +tdc comment delete --json # Delete and return status as JSON \`\`\` ## Conversations (DMs/Groups) \`\`\`bash -cm conversation unread # List unread conversations -cm conversation # View conversation (shorthand for view) -cm conversation view # View conversation messages -cm conversation with # Find your 1:1 DM with a user -cm conversation with --snippet # Include the latest message preview -cm conversation with --include-groups # List any conversations with that user -cm conversation reply "content" # Send a message -cm conversation reply "content" --json # Send and return message as JSON -cm conversation reply "content" --json --full # Include all message fields -cm conversation done # Archive conversation -cm conversation done --json # Archive and return status as JSON -cm conversation mute # Mute conversation for 60 minutes (default) -cm conversation mute --minutes 480 # Mute for custom duration -cm conversation mute --json # Mute and return { id, mutedUntil } as JSON -cm conversation mute --json --full # Mute and return full conversation as JSON -cm conversation unmute # Unmute a muted conversation -cm conversation unmute --json # Unmute and return { id, mutedUntil } as JSON +tdc conversation unread # List unread conversations +tdc conversation # View conversation (shorthand for view) +tdc conversation view # View conversation messages +tdc conversation with # Find your 1:1 DM with a user +tdc conversation with --snippet # Include the latest message preview +tdc conversation with --include-groups # List any conversations with that user +tdc conversation reply "content" # Send a message +tdc conversation reply "content" --json # Send and return message as JSON +tdc conversation reply "content" --json --full # Include all message fields +tdc conversation done # Archive conversation +tdc conversation done --json # Archive and return status as JSON +tdc conversation mute # Mute conversation for 60 minutes (default) +tdc conversation mute --minutes 480 # Mute for custom duration +tdc conversation mute --json # Mute and return { id, mutedUntil } as JSON +tdc conversation mute --json --full # Mute and return full conversation as JSON +tdc conversation unmute # Unmute a muted conversation +tdc conversation unmute --json # Unmute and return { id, mutedUntil } as JSON \`\`\` -Alias: \`cm convo\` works the same as \`cm conversation\`. +Alias: \`tdc convo\` works the same as \`tdc conversation\`. ## Conversation Messages \`\`\`bash -cm msg # View a message (shorthand for view) -cm msg view # View a single conversation message -cm msg update "content" # Edit a conversation message -cm msg update "content" --json # Edit and return updated message as JSON -cm msg update "content" --json --full # Include all message fields -cm msg delete # Delete a conversation message -cm msg delete --json # Delete and return status as JSON +tdc msg # View a message (shorthand for view) +tdc msg view # View a single conversation message +tdc msg update "content" # Edit a conversation message +tdc msg update "content" --json # Edit and return updated message as JSON +tdc msg update "content" --json --full # Include all message fields +tdc msg delete # Delete a conversation message +tdc msg delete --json # Delete and return status as JSON \`\`\` -Alias: \`cm message\` works the same as \`cm msg\`. +Alias: \`tdc message\` works the same as \`tdc msg\`. ## Search \`\`\`bash -cm mentions # Show content mentioning current user -cm mentions --since 2026-04-01 --all # Fetch every mention since a date -cm mentions --type threads --json # Limit mentions to threads -cm search "query" # Search content -cm search "query" --type threads # Filter: threads, messages, or all -cm search "query" --author # Filter by author -cm search "query" --to # Messages sent to user -cm search "query" --title-only # Search thread titles only -cm search "query" --mention-me # Results mentioning current user -cm search "query" --conversation # Limit to conversations (comma-separated refs) -cm search "query" --since # Content from date -cm search "query" --until # Content until date -cm search "query" --channel # Filter by channel refs (comma-separated) -cm search "query" --limit # Max results (default: 50) -cm search "query" --cursor # Pagination cursor -cm search "query" --all # Fetch all result pages +tdc mentions # Show content mentioning current user +tdc mentions --since 2026-04-01 --all # Fetch every mention since a date +tdc mentions --type threads --json # Limit mentions to threads +tdc search "query" # Search content +tdc search "query" --type threads # Filter: threads, messages, or all +tdc search "query" --author # Filter by author +tdc search "query" --to # Messages sent to user +tdc search "query" --title-only # Search thread titles only +tdc search "query" --mention-me # Results mentioning current user +tdc search "query" --conversation # Limit to conversations (comma-separated refs) +tdc search "query" --since # Content from date +tdc search "query" --until # Content until date +tdc search "query" --channel # Filter by channel refs (comma-separated) +tdc search "query" --limit # Max results (default: 50) +tdc search "query" --cursor # Pagination cursor +tdc search "query" --all # Fetch all result pages \`\`\` ## Users, Channels & Groups \`\`\`bash -cm user # Show current user info -cm user --json # JSON output -cm user --json --full # Include all fields in JSON output -cm users # List workspace users -cm users --search # Filter by name/email -cm channels # List active joined workspace channels (alias of: cm channel list) -cm channels --state all # Include archived joined channels too -cm channels --scope discoverable # Active public channels you can see but have not joined -cm channels --scope public --state all --json # All visible public channels, with joined status -cm channel threads # List threads in a channel (fuzzy name, id:, numeric ID, or URL) -cm channel threads "general" --unread # Only unread threads -cm channel threads --archive-filter all # Include archived threads (active|archived|all) -cm channel threads --since 2026-01-01 # Filter by last-updated date (ISO) -cm channel threads --limit 20 # Max threads per page (default: 50) -cm channel threads --limit 20 --cursor # Paginate -cm channel threads --json # { results, nextCursor } with isUnread + url -cm groups # List workspace groups -cm groups --search "frontend" # Filter groups by name (case-insensitive) -cm groups --json # JSON output -cm groups --json --full # Include all fields in JSON output -cm groups view # Show group with member details -cm groups view --json # JSON output with id, name, workspaceId, members -cm groups view --json --full # Include all fields in JSON output -cm groups create "Name" # Create a new group -cm groups create "Name" --users alice@doist.com,bob@doist.com # Create with members -cm groups create "Name" --json # Output created group as JSON -cm groups rename "New name" # Rename a group -cm groups rename "Name" --json # Output renamed group as JSON -cm groups delete --yes # Delete a group (requires --yes) -cm groups delete --dry-run # Preview deletion -cm groups add-user user1 user2 # Add users to a group -cm groups add-user a@d.com,b@d.com # Comma-separated refs -cm groups add-user id:123 --json # Output result as JSON -cm groups remove-user user1 user2 # Remove users from a group -cm groups remove-user id:123,id:456 # Comma-separated ID refs +tdc user # Show current user info +tdc user --json # JSON output +tdc user --json --full # Include all fields in JSON output +tdc users # List workspace users +tdc users --search # Filter by name/email +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 channel threads # List threads in a channel (fuzzy name, id:, numeric ID, or URL) +tdc channel threads "general" --unread # Only unread threads +tdc channel threads --archive-filter all # Include archived threads (active|archived|all) +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 groups # List workspace groups +tdc groups --search "frontend" # Filter groups by name (case-insensitive) +tdc groups --json # JSON output +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 +tdc groups view --json --full # Include all fields in JSON output +tdc groups create "Name" # Create a new group +tdc groups create "Name" --users alice@doist.com,bob@doist.com # Create with members +tdc groups create "Name" --json # Output created group as JSON +tdc groups rename "New name" # Rename a group +tdc groups rename "Name" --json # Output renamed group as JSON +tdc groups delete --yes # Delete a group (requires --yes) +tdc groups delete --dry-run # Preview deletion +tdc groups add-user user1 user2 # Add users to a group +tdc groups add-user a@d.com,b@d.com # Comma-separated refs +tdc groups add-user id:123 --json # Output result as JSON +tdc groups remove-user user1 user2 # Remove users from a group +tdc groups remove-user id:123,id:456 # Comma-separated ID refs \`\`\` -If a channel is not found in \`cm channels\`, widen with broader listings such as \`cm channels --scope public\`, then \`cm channels --scope public --state all\`. Check \`cm channels --help\` for other available filters. +If a channel is not found in \`tdc channels\`, widen with broader listings such as \`tdc channels --scope public\`, then \`tdc channels --scope public --state all\`. Check \`tdc channels --help\` for other available filters. -\`cm channel threads\` returns every thread in the channel; pagination filters (\`--limit\`, \`--cursor\`, \`--since\`, \`--until\`, \`--unread\`) are applied client-side after fetch. \`--archive-filter\` is applied server-side. Results are sorted newest-first by last activity. In \`--json\` / \`--ndjson\`, the response includes a \`nextCursor\` string (opaque) you can pass via \`--cursor\` to fetch the next page; NDJSON emits the cursor as a final \`{ "_meta": true, "nextCursor": "..." }\` line. +\`tdc channel threads\` returns every thread in the channel; pagination filters (\`--limit\`, \`--cursor\`, \`--since\`, \`--until\`, \`--unread\`) are applied client-side after fetch. \`--archive-filter\` is applied server-side. Results are sorted newest-first by last activity. In \`--json\` / \`--ndjson\`, the response includes a \`nextCursor\` string (opaque) you can pass via \`--cursor\` to fetch the next page; NDJSON emits the cursor as a final \`{ "_meta": true, "nextCursor": "..." }\` line. ## Away Status \`\`\`bash -cm away # Show current away status -cm away set [until] # Set away (type: vacation, parental, sickleave, other) -cm away set vacation 2026-03-20 # Away until March 20 -cm away set vacation 2026-03-20 --from 2026-03-15 # Custom start date -cm away clear # Clear away status +tdc away # Show current away status +tdc away set [until] # Set away (type: vacation, parental, sickleave, other) +tdc away set vacation 2026-03-20 # Away until March 20 +tdc away set vacation 2026-03-20 --from 2026-03-15 # Custom start date +tdc away clear # Clear away status \`\`\` ## Reactions \`\`\`bash -cm react thread 👍 # Add reaction to thread -cm react comment +1 # Add reaction (shortcode) -cm react message heart # Add reaction to DM message -cm react thread 👍 --json # Output result as JSON -cm unreact thread 👍 # Remove reaction -cm unreact thread 👍 --json # Output result as JSON +tdc react thread 👍 # Add reaction to thread +tdc react comment +1 # Add reaction (shortcode) +tdc react message heart # Add reaction to DM message +tdc react thread 👍 --json # Output result as JSON +tdc unreact thread 👍 # Remove reaction +tdc unreact thread 👍 --json # Output result as JSON \`\`\` Supported shortcodes: +1, -1, heart, tada, smile, laughing, thinking, fire, check, x, eyes, pray, clap, rocket, wave @@ -272,52 +272,52 @@ Supported shortcodes: +1, -1, heart, tada, smile, laughing, thinking, fire, chec ## Shell Completions \`\`\`bash -cm completion install # Install tab completions (prompts for shell) -cm completion install bash # Install for specific shell -cm completion install zsh -cm completion install fish -cm completion uninstall # Remove completions +tdc completion install # Install tab completions (prompts for shell) +tdc completion install bash # Install for specific shell +tdc completion install zsh +tdc completion install fish +tdc completion uninstall # Remove completions \`\`\` ### Diagnostics \`\`\`bash -cm doctor # Run local + network diagnostics -cm doctor --offline # Skip Comms and npm network checks -cm doctor --json # JSON output with per-check results +tdc doctor # Run local + network diagnostics +tdc doctor --offline # Skip Comms and npm network checks +tdc doctor --json # JSON output with per-check results \`\`\` ### Configuration \`\`\`bash -cm config view # Pretty-printed config, token masked, labels actual token source -cm config view --json # Raw JSON, token masked -cm config view --show-token # Include the full token -cm config set unarchive-new-threads true # Persist: always unarchive new threads so they land in your Inbox -cm config set unarchive-new-threads false # Persist: keep Comms's default (thread auto-archived for author) +tdc config view # Pretty-printed config, token masked, labels actual token source +tdc config view --json # Raw JSON, token masked +tdc config view --show-token # Include the full token +tdc config set unarchive-new-threads true # Persist: always unarchive new threads so they land in your Inbox +tdc config set unarchive-new-threads false # Persist: keep Comms's default (thread auto-archived for author) \`\`\` -User preferences are stored under \`userSettings\` in the config file. Currently supported keys: \`unarchive-new-threads\`. The flag on \`cm thread create\` (\`--unarchive\` / \`--no-unarchive\`) overrides this default per-invocation. +User preferences are stored under \`userSettings\` in the config file. Currently supported keys: \`unarchive-new-threads\`. The flag on \`tdc thread create\` (\`--unarchive\` / \`--no-unarchive\`) overrides this default per-invocation. ### Update \`\`\`bash -cm update # Update CLI to latest version -cm update --check # Check for updates without installing, show channel -cm update --check --json # Same, JSON envelope -cm update --check --ndjson # Same, newline-delimited JSON envelope -cm update --channel # Show current update channel -cm update switch --stable # Switch to stable release channel -cm update switch --pre-release # Switch to pre-release (next) channel -cm update switch --pre-release --json # Same, JSON envelope -cm update switch --pre-release --ndjson # Same, newline-delimited JSON envelope +tdc update # Update CLI to latest version +tdc update --check # Check for updates without installing, show channel +tdc update --check --json # Same, JSON envelope +tdc update --check --ndjson # Same, newline-delimited JSON envelope +tdc update --channel # Show current update channel +tdc update switch --stable # Switch to stable release channel +tdc update switch --pre-release # Switch to pre-release (next) channel +tdc update switch --pre-release --json # Same, JSON envelope +tdc update switch --pre-release --ndjson # Same, newline-delimited JSON envelope \`\`\` ### Changelog \`\`\`bash -cm changelog # Show last 5 versions -cm changelog -n 3 # Show last 3 versions -cm changelog --count 10 # Show last 10 versions +tdc changelog # Show last 5 versions +tdc changelog -n 3 # Show last 3 versions +tdc changelog --count 10 # Show last 10 versions \`\`\` ## Global Options @@ -327,7 +327,7 @@ cm changelog --count 10 # Show last 10 versions --progress-jsonl # Machine-readable progress events (JSONL to stderr) --progress-jsonl= # Same, but write events to instead of stderr --progress-jsonl # Same as above (space-separated form also accepted) ---accessible # Add text labels to color-coded output (also: CM_ACCESSIBLE=1) +--accessible # Add text labels to color-coded output (also: TDC_ACCESSIBLE=1) --non-interactive # Disable interactive prompts (auto-detected when stdin is not a TTY) --interactive # Force interactive mode even when stdin is not a TTY \`\`\` @@ -365,9 +365,9 @@ Commands accept flexible references: Commands that accept content (\`thread create\`, \`thread reply\`, \`comment update\`, \`conversation reply\`, \`msg update\`) auto-detect piped stdin: \`\`\`bash -cat notes.md | cm thread reply -cm thread create "Title" < body.md -echo "Quick reply" | cm conversation reply +cat notes.md | tdc thread reply +tdc thread create "Title" < body.md +echo "Quick reply" | tdc conversation reply \`\`\` If no content argument is provided and no stdin is piped, the CLI opens \`$EDITOR\` for interactive input. In non-TTY environments (e.g. when called by an agent or in a pipeline), the editor is automatically skipped and the command fails fast with an actionable error message. Use \`--non-interactive\` to force this behavior even in a TTY, or \`--interactive\` to override auto-detection. @@ -376,33 +376,33 @@ If no content argument is provided and no stdin is piped, the CLI opens \`$EDITO **View by URL (auto-routes to the right command):** \`\`\`bash -cm view https://comms.todoist.com/a/1585/ch/100/t/200 # View thread -cm view https://comms.todoist.com/a/1585/ch/100/t/200/c/300 # View comment -cm view https://comms.todoist.com/a/1585/msg/400 # View conversation -cm view https://comms.todoist.com/a/1585/msg/400/m/500 --json # View message as JSON +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 +tdc view https://comms.todoist.com/a/1585/msg/400/m/500 --json # View message as JSON \`\`\` **Check inbox and respond:** \`\`\`bash -cm inbox --unread --json -cm thread view --unread -cm thread reply "Thanks, I'll look into this." -cm thread done +tdc inbox --unread --json +tdc thread view --unread +tdc thread reply "Thanks, I'll look into this." +tdc thread done \`\`\` **Search and review:** \`\`\`bash -cm mentions --since 2026-04-01 --all --json -cm search "deployment" --type threads --json -cm thread view +tdc mentions --since 2026-04-01 --all --json +tdc search "deployment" --type threads --json +tdc thread view \`\`\` **Check DMs:** \`\`\`bash -cm conversation unread --json -cm conversation view -cm conversation with "Alice Example" -cm conversation reply "Got it, thanks!" +tdc conversation unread --json +tdc conversation view +tdc conversation with "Alice Example" +tdc conversation reply "Got it, thanks!" \`\`\` ` diff --git a/src/lib/skills/create-installer.ts b/src/lib/skills/create-installer.ts index 7b35e14..20017db 100644 --- a/src/lib/skills/create-installer.ts +++ b/src/lib/skills/create-installer.ts @@ -65,7 +65,7 @@ export function createInstaller(config: InstallerConfig): SkillInstaller { if (!exists) { throw new CliError('NOT_INSTALLED', `Skill not installed at ${skillPath}`, [ - `Run: cm skill install ${config.name}`, + `Run: tdc skill install ${config.name}`, ]) } @@ -78,7 +78,7 @@ export function createInstaller(config: InstallerConfig): SkillInstaller { if (!exists) { throw new CliError('NOT_INSTALLED', `Skill not installed at ${skillPath}`, [ - `Run: cm skill install ${config.name}`, + `Run: tdc skill install ${config.name}`, ]) } diff --git a/src/lib/update.ts b/src/lib/update.ts index 1b7dfc1..26f8f0a 100644 --- a/src/lib/update.ts +++ b/src/lib/update.ts @@ -9,10 +9,10 @@ export async function fetchLatestVersion(channel: UpdateChannel): Promise Date: Wed, 20 May 2026 19:53:13 +0200 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20address=20Doistbot=20pass=204=20?= =?UTF-8?q?=E2=80=94=20manual-token=20handling=20+=20revert=20CI=20cross-r?= =?UTF-8?q?epo=20checkout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doistbot pass 4 raised three new issues; this commit addresses them. **Manual-token handling across the account command group (P2 + P3):** - Extract `MANUAL_TOKEN_ACCOUNT` constant and `isManualTokenAccount()` predicate in `auth-provider.ts` to centralise the `{ id: '', label: '' }` contract that `tdc auth token` writes. - `tdc account list` now filters manual-token snapshots out of the rendered rows — they have no identity to display. - `findAccountInStore` skips manual-token entries when resolving refs, so `tdc account use|remove ` can no longer accidentally target the identity-less snapshot. - `auth/token.ts` and `auth-provider.ts` now use the shared `MANUAL_TOKEN_ACCOUNT` constant instead of inline literals. - Test assertion for token-only path no longer relies on a brittle spacing regex — asserts the regular header strings are absent. **Revert CI cross-repo checkout (P1 security + CI failure):** - The previous commit added `actions/checkout@v5` against `Doist/comms-sdk-typescript` with `secrets.GITHUB_TOKEN`. Doistbot flagged this as a Secrets Management Standard violation (secrets must not be used in `pull_request` workflows that execute untrusted PR code afterwards), and it also failed at runtime because the default `GITHUB_TOKEN` cannot access sibling private repos. - Lint / test / skill-sync workflows are reverted to their pre-cleanup shape. CI will continue to fail on `npm ci` until `@doist/comms-sdk` is published to a private npm registry — that is a follow-up to the bootstrap commit, not the cleanup PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/check-skill-sync.yml | 16 +------------ .github/workflows/lint.yml | 15 +------------ .github/workflows/test.yml | 15 +------------ src/commands/account/account.test.ts | 7 +++++- src/commands/account/current.ts | 10 ++++----- src/commands/account/list.ts | 18 ++++++++++----- src/commands/auth/token.ts | 4 ++-- src/lib/auth-provider.ts | 31 +++++++++++++++++++++----- 8 files changed, 54 insertions(+), 62 deletions(-) diff --git a/.github/workflows/check-skill-sync.yml b/.github/workflows/check-skill-sync.yml index c2491fe..94413e3 100644 --- a/.github/workflows/check-skill-sync.yml +++ b/.github/workflows/check-skill-sync.yml @@ -12,39 +12,25 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - name: Checkout Comms SDK (sibling dep via file:../comms-sdk-typescript) - uses: actions/checkout@v5 - with: - repository: Doist/comms-sdk-typescript - path: comms-sdk-typescript - token: ${{ secrets.GITHUB_TOKEN }} - - name: Checkout uses: actions/checkout@v5 - with: - path: comms-cli - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version-file: comms-cli/.nvmrc + node-version-file: '.nvmrc' cache: 'npm' - cache-dependency-path: comms-cli/package-lock.json - name: Install dependencies - working-directory: comms-cli run: npm ci - name: Build - working-directory: comms-cli run: npm run build - name: Check SKILL.md is in sync - working-directory: comms-cli run: npm run check:skill-sync - name: Validate SKILL.md against agentskills.io spec - working-directory: comms-cli run: | if ! gh skill --help >/dev/null 2>&1; then echo "::notice::gh skill subcommand not available on this runner (requires gh >= 2.90.0); skipping validation" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0df6415..2040445 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,33 +12,20 @@ jobs: timeout-minutes: 10 steps: - - name: Checkout Comms SDK (sibling dep via file:../comms-sdk-typescript) - uses: actions/checkout@v5 - with: - repository: Doist/comms-sdk-typescript - path: comms-sdk-typescript - token: ${{ secrets.GITHUB_TOKEN }} - - name: Checkout uses: actions/checkout@v5 - with: - path: comms-cli - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version-file: comms-cli/.nvmrc + node-version-file: '.nvmrc' cache: 'npm' - cache-dependency-path: comms-cli/package-lock.json - name: Install dependencies - working-directory: comms-cli run: npm ci - name: Type check - working-directory: comms-cli run: npm run type-check - name: Lint & format check - working-directory: comms-cli run: npm run lint:check diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03721a9..5e3a9de 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,33 +12,20 @@ jobs: timeout-minutes: 10 steps: - - name: Checkout Comms SDK (sibling dep via file:../comms-sdk-typescript) - uses: actions/checkout@v5 - with: - repository: Doist/comms-sdk-typescript - path: comms-sdk-typescript - token: ${{ secrets.GITHUB_TOKEN }} - - name: Checkout uses: actions/checkout@v5 - with: - path: comms-cli - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version-file: comms-cli/.nvmrc + node-version-file: '.nvmrc' cache: 'npm' - cache-dependency-path: comms-cli/package-lock.json - name: Install dependencies - working-directory: comms-cli run: npm ci - name: Build - working-directory: comms-cli run: npm run build - name: Run tests - working-directory: comms-cli run: npm test diff --git a/src/commands/account/account.test.ts b/src/commands/account/account.test.ts index 02a32fe..d17194e 100644 --- a/src/commands/account/account.test.ts +++ b/src/commands/account/account.test.ts @@ -176,7 +176,12 @@ describe('account command', () => { const output = stdout() expect(output).toContain('saved via `tdc auth token`') - expect(output).not.toMatch(/Active account: id: {2}/) + // The token-only path must skip the regular `Active account: …` + // header entirely — otherwise a future change could resurrect a + // blank-fields render of the empty-id snapshot. + expect(output).not.toContain('Active account:') + expect(output).not.toContain('Mode:') + expect(output).not.toContain('Scope:') }) it('emits {source:"token-only"} in --json mode for empty-id snapshots', async () => { diff --git a/src/commands/account/current.ts b/src/commands/account/current.ts index 3b7a523..06b33d0 100644 --- a/src/commands/account/current.ts +++ b/src/commands/account/current.ts @@ -1,6 +1,6 @@ import { emitView } from '@doist/cli-core' import chalk from 'chalk' -import type { CommsTokenStore } from '../../lib/auth-provider.js' +import { type CommsTokenStore, isManualTokenAccount } from '../../lib/auth-provider.js' import { TOKEN_ENV_VAR } from '../../lib/auth.js' import { CliError } from '../../lib/errors.js' import type { ViewOptions } from '../../lib/options.js' @@ -21,10 +21,10 @@ export async function currentAccount(options: ViewOptions, store: CommsTokenStor } const { account } = snapshot - // `tdc auth token` persists `{ id: '', label: '' }` because manual token - // entry has no identity. Render that case explicitly rather than printing - // blank fields. - if (!account.id || !account.label) { + // `tdc auth token` persists `MANUAL_TOKEN_ACCOUNT` (empty id/label) because + // manual token entry has no identity. Render that case explicitly rather + // than printing blank fields. + if (isManualTokenAccount(account)) { emitView(options, { source: 'token-only' }, () => [ 'Active token saved via `tdc auth token` (no associated identity).', chalk.dim('Run `tdc auth login` to attach an account to the token.'), diff --git a/src/commands/account/list.ts b/src/commands/account/list.ts index 72048e8..af17ebe 100644 --- a/src/commands/account/list.ts +++ b/src/commands/account/list.ts @@ -1,15 +1,21 @@ import chalk from 'chalk' -import type { CommsTokenStore } from '../../lib/auth-provider.js' +import { type CommsTokenStore, isManualTokenAccount } from '../../lib/auth-provider.js' import type { ViewOptions } from '../../lib/options.js' import { formatJson, formatNdjson } from '../../lib/output.js' export async function listAccounts(options: ViewOptions, store: CommsTokenStore): Promise { const records = await store.list() - const rows = records.map(({ account, isDefault }) => ({ - id: account.id, - label: account.label, - isDefault, - })) + // Manual-token snapshots (from `tdc auth token`) have no identity and + // can't be targeted by `tdc account use|remove` — hide them from + // listings so users only see actionable rows. `tdc account current` + // surfaces the active manual-token state separately. + const rows = records + .filter(({ account }) => !isManualTokenAccount(account)) + .map(({ account, isDefault }) => ({ + id: account.id, + label: account.label, + isDefault, + })) if (options.json) return console.log(formatJson(rows)) if (options.ndjson) return console.log(formatNdjson(rows)) diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 571d5a0..b059fe0 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -1,6 +1,6 @@ import { createInterface } from 'node:readline' import chalk from 'chalk' -import { createCommsTokenStore } from '../../lib/auth-provider.js' +import { 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' @@ -52,7 +52,7 @@ export async function loginWithToken(): Promise { // `authUserId: undefined` for it and the synthesised record is what later // `active()` / `list()` reads will return. const store = createCommsTokenStore() - await store.set({ id: '', label: '', authMode: 'unknown', authScope: '' }, trimmed) + await store.set(MANUAL_TOKEN_ACCOUNT, trimmed) console.log(chalk.green('✓'), 'API token saved successfully!') const result = store.getLastStorageResult() if (result) { diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts index 0c6fb59..c574b82 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -73,6 +73,25 @@ export type CommsAccount = AuthAccount & { export type CommsTokenStore = KeyringTokenStore +/** + * Sentinel for the `{ id: '', label: '' }` snapshot that `tdc auth token` + * persists when the user passes a raw token with no identity attached. The + * empty-id/empty-label pair is the contract between `loginWithToken` (writer) + * and `account current` / `account list` (readers); centralise it here so + * each call site agrees on the shape. + */ +export const MANUAL_TOKEN_ACCOUNT: CommsAccount = { + id: '', + label: '', + authMode: 'unknown', + authScope: '', +} + +/** True when `account` is the identity-less snapshot produced by `tdc auth token`. */ +export function isManualTokenAccount(account: Pick): boolean { + return !account.id || !account.label +} + type CommsHandshake = Record & { clientId: string clientSecret: string @@ -284,7 +303,12 @@ export async function findAccountInStore( ref: AccountRef, ): Promise { const records = await store.list() - const match = records.find(({ account }) => matchCommsAccount(account, ref)) + // Manual-token snapshots have no id/label and can't be the target of a + // ref-based command. Excluding them here keeps `tdc account use|remove` + // honest with what `tdc account list` shows. + const match = records + .filter(({ account }) => !isManualTokenAccount(account)) + .find(({ account }) => matchCommsAccount(account, ref)) if (!match) { throw new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`, [ 'Run: tdc account list', @@ -310,10 +334,7 @@ export function createCommsTokenStore(): CommsTokenStore { if (ref === undefined) { const envToken = process.env[TOKEN_ENV_VAR] if (envToken) { - return { - token: envToken, - account: { id: '', label: '', authMode: 'unknown', authScope: '' }, - } + return { token: envToken, account: MANUAL_TOKEN_ACCOUNT } } } return inner.active(ref)