Skill: drkraft/basecamp-cli-mcp (version 2.0.0)

--- name: basecamp-cli description: CLI and MCP server for Basecamp 4. Use when you need to interact with Basecamp projects, todos, messages, schedules, kanban cards, documents, or campfires. Provides 76 MCP tools for AI-driven project management workflows. mcp: true metadata: {"openclaw":{"emoji":"🏕️","homepage":"https://github.com/drkraft/basecamp-cli","primaryEnv":"BASECAMP_CLIENT_SECRET","requires":{"bins":["basecamp-mcp"],"env":["BASECAMP_CLIENT_ID","BASECAMP_CLIENT_SECRET"]},"install":[{"id":"npm","kind":"node","package":"@drkraft/basecamp-cli","bins":["basecamp","basecamp-mcp"],"label":"Install @drkraft/basecamp-cli (npm)","global":true}]}} --- # Basecamp CLI Full-featured CLI and MCP server for Basecamp 4 API. ## Features - **21 CLI command groups** covering all Basecamp 4 domains - **76 MCP tools** for AI assistant integration - Automatic pagination and retry with exponential backoff - OAuth 2.0 authentication with PKCE ## Installation ```bash npm install -g @drkraft/basecamp-cli ``` ## Requirements - Node.js >= 20 ## Authentication Setup 1. Create an OAuth app at https://launchpad.37signals.com/integrations - Set redirect URI to `http://localhost:9292/callback` 2. Configure credentials: ```bash basecamp auth configure --client-id <your-client-id> export BASECAMP_CLIENT_SECRET="<your-client-secret>" export BASECAMP_CLIENT_ID="<your-client-id>" ``` 3. Login: ```bash basecamp auth login ``` ## MCP Server Configuration Add to your MCP config (e.g., `~/.config/claude/claude_desktop_config.json`): ```json { "mcpServers": { "basecamp": { "command": "basecamp-mcp", "env": { "BASECAMP_CLIENT_ID": "<your-client-id>", "BASECAMP_CLIENT_SECRET": "<your-client-secret>" } } } } ``` ## Available MCP Tools (76) | Category | Tools | |----------|-------| | Projects | `basecamp_list_projects`, `basecamp_get_project`, `basecamp_create_project`, `basecamp_archive_project` | | Todo Lists | `basecamp_list_todolists`, `basecamp_get_todolist`, `basecamp_create_todolist`, `basecamp_delete_todolist` | | Todo Groups | `basecamp_list_todolist_groups`, `basecamp_create_todolist_group` | | Todos | `basecamp_list_todos`, `basecamp_get_todo`, `basecamp_create_todo`, `basecamp_update_todo`, `basecamp_complete_todo`, `basecamp_uncomplete_todo`, `basecamp_delete_todo`, `basecamp_move_todo` | | Messages | `basecamp_list_messages`, `basecamp_get_message`, `basecamp_create_message` | | People | `basecamp_list_people`, `basecamp_get_person`, `basecamp_get_me` | | Comments | `basecamp_list_comments`, `basecamp_get_comment`, `basecamp_create_comment`, `basecamp_update_comment`, `basecamp_delete_comment` | | Vaults | `basecamp_list_vaults`, `basecamp_get_vault`, `basecamp_create_vault`, `basecamp_update_vault` | | Documents | `basecamp_list_documents`, `basecamp_get_document`, `basecamp_create_document`, `basecamp_update_document` | | Uploads | `basecamp_list_uploads`, `basecamp_get_upload`, `basecamp_create_upload`, `basecamp_update_upload` | | Schedules | `basecamp_get_schedule`, `basecamp_list_schedule_entries`, `basecamp_get_schedule_entry`, `basecamp_create_schedule_entry`, `basecamp_update_schedule_entry`, `basecamp_delete_schedule_entry` | | Card Tables | `basecamp_get_card_table`, `basecamp_get_column`, `basecamp_create_column`, `basecamp_update_column`, `basecamp_delete_column`, `basecamp_list_cards`, `basecamp_get_card`, `basecamp_create_card`, `basecamp_update_card`, `basecamp_move_card`, `basecamp_delete_card` | | Search | `basecamp_search` | | Recordings | `basecamp_list_recordings`, `basecamp_archive_recording`, `basecamp_restore_recording`, `basecamp_trash_recording` | | Subscriptions | `basecamp_list_subscriptions`, `basecamp_subscribe`, `basecamp_unsubscribe` | | Webhooks | `basecamp_list_webhooks`, `basecamp_get_webhook`, `basecamp_create_webhook`, `basecamp_update_webhook`, `basecamp_delete_webhook`, `basecamp_test_webhook` | | Events | `basecamp_list_events` | | Campfires | `basecamp_list_campfires`, `basecamp_get_campfire_lines`, `basecamp_send_campfire_line` | ## CLI Quick Reference ```bash # Projects basecamp projects list basecamp projects get <id> # Todos basecamp todolists list --project <id> basecamp todos list --project <id> --list <list-id> basecamp todos create --project <id> --list <list-id> --content "Task" basecamp todos complete <id> --project <id> basecamp todos delete <id> --project <id> basecamp todos move <id> --project <id> --list <target-list-id> # Messages basecamp messages list --project <id> basecamp messages create --project <id> --subject "Title" --content "<p>Body</p>" # Kanban basecamp cardtables get --project <id> basecamp cardtables cards --project <id> --column <col-id> basecamp cardtables create-card --project <id> --column <col-id> --title "Card" # Search basecamp search "keyword" basecamp search "keyword" --type Todo --project <id> ``` All commands support `--format json` for JSON output. ## Links - [Full Documentation](https://github.com/drkraft/basecamp-cli) - [npm Package](https://www.npmjs.com/package/@drkraft/basecamp-cli) - [Basecamp API Reference](https://github.com/basecamp/bc3-api)

path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.0] - 2026-02-01

### Added

#### New Command Groups (12)
- **Comments**: List, get, create, update, delete comments on any recording
- **Vaults**: List, get, create folder hierarchies in Docs & Files
- **Documents**: List, get, create, update text documents
- **Uploads**: List, get uploaded files
- **Schedules**: Get schedule, list/create/update/delete schedule entries
- **Card Tables**: Full Kanban support - tables, columns, cards, card movement
- **Webhooks**: List, get, create, update, delete webhook subscriptions
- **Recordings**: Cross-project listing by type, archive/restore/trash
- **Events**: Activity feed per project
- **Search**: Global search with type/project/creator filters
- **Subscriptions**: List subscribers, subscribe/unsubscribe from recordings
- **Todo Groups**: List and create todolist groups
- **Delete commands**: `todos delete` and `todolists delete` (move to trash)
- **Move command**: `todos move` to move tasks between lists
- **Restore command**: `recordings restore` to restore from archive or trash

#### MCP Server
- New MCP (Model Context Protocol) server with 44 tools
- Full integration with AI assistants (Claude, OpenCode, etc.)
- All tools documented with JSON Schema

#### Infrastructure
- Automatic pagination with `fetchAllPages()` helper
- Retry logic with exponential backoff for 429/5xx errors
- Respects `Retry-After` header from Basecamp
- Test infrastructure with vitest and msw
- Validation script for manual testing

### Changed
- **BREAKING**: Rebranded from `@emredoganer/basecamp-cli` to `@drkraft/basecamp-cli`
- **BREAKING**: Updated User-Agent to `@drkraft/basecamp-cli ([REDACTED_SECRET])`
- Standardized CLI interface with `--format table|json` for all commands
- Added `--verbose` global flag for debugging
- Moved `basecamp me` to `basecamp people me` for consistency

### Migration from v1.x

1. Update your installation:
```bash
npm uninstall -g @emredoganer/basecamp-cli
npm install -g @drkraft/basecamp-cli
```

2. Update command aliases if you used `basecamp me`:
```bash
# Old
basecamp me

# New
basecamp people me
```

## [1.0.0] - 2024-XX-XX

### Added
- Initial release of Basecamp CLI
- OAuth 2.0 authentication support
- Project management commands
- To-do list and to-do management
- Message board support
- Campfire (chat) support
- People management
- JSON output format support


path: CONTRIBUTING.md
content: # Contributing to @drkraft/basecamp-cli

Thanks for your interest in contributing!

## Development Setup

1. Clone the repository:
```bash
git clone https://github.com/drkraft/basecamp-cli
cd basecamp-cli
```

2. Install dependencies:
```bash
bun install
```

3. Build:
```bash
bun run build
```

4. Run tests:
```bash
bun test
```

## Project Structure

```
src/
├── commands/        # CLI command implementations
├── lib/
│   ├── api.ts       # Basecamp API functions
│   ├── auth.ts      # OAuth handling
│   └── config.ts    # Config management
├── mcp/
│   ├── server.ts    # MCP server setup
│   └── tools/       # MCP tool implementations
├── types/           # TypeScript interfaces
└── __tests__/       # Test files
```

## Adding a New Command

1. Create the command file in `src/commands/`:
```typescript
// src/commands/example.ts
import { Command } from 'commander';
import * as api from '../lib/api.js';
import { formatOutput } from '../lib/format.js';

export function createExampleCommand(): Command {
  const cmd = new Command('example')
    .description('Example command group');

  cmd
    .command('list')
    .description('List examples')
    .option('--project <id>', 'Project ID')
    .option('--format <type>', 'Output format', 'table')
    .action(async (options) => {
      const result = await api.listExamples(options.project);
      console.log(formatOutput(result, options.format));
    });

  return cmd;
}
```

2. Register in `src/index.ts`:
```typescript
import { createExampleCommand } from './commands/example.js';
program.addCommand(createExampleCommand());
```

3. Add API functions in `src/lib/api.ts`

4. Add types in `src/types/index.ts`

5. Add tests in `src/__tests__/example.test.ts`

## Adding an MCP Tool

Add to `src/mcp/tools/index.ts`:

```typescript
{
  name: 'basecamp_example',
  description: 'Description of what this tool does',
  inputSchema: {
    type: 'object',
    properties: {
      param: {
        type: 'string',
        description: 'Parameter description',
      },
    },
    required: ['param'],
  },
  handler: async (args) => api.example(args.param as string),
},
```

## Testing

- Unit tests use vitest with msw for mocking HTTP
- Run `bun test` before committing
- Manual validation: `bun run scripts/validate.ts`

## Commit Messages

Follow conventional commits:
- `feat(scope): add new feature`
- `fix(scope): fix bug`
- `docs: update documentation`
- `test: add tests`
- `chore: maintenance tasks`

## Pull Requests

1. Fork the repository
2. Create a feature branch: `git checkout -b feat/my-feature`
3. Make your changes
4. Run tests: `bun test`
5. Build: `bun run build`
6. Submit a PR

## Questions?

Open an issue on GitHub.


path: README.md
content: # @drkraft/basecamp-cli

A comprehensive command-line interface and MCP server for Basecamp 4. Manage projects, to-dos, messages, schedules, kanban boards, and more from your terminal or AI assistant.

[![npm version](https://badge.fury.io/js/%40drkraft%2Fbasecamp-cli.svg)](https://www.npmjs.com/package/@drkraft/basecamp-cli)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![codecov](https://codecov.io/gh/drkraft/basecamp-cli/branch/main/graph/badge.svg)](https://codecov.io/gh/drkraft/basecamp-cli)

## Features

- **Full CLI** - 21 command groups covering the complete Basecamp 4 API
- **MCP Server** - 76 tools for AI assistant integration (Claude, etc.)
- **Multiple Output Formats** - Table or JSON output for all commands
- **Pagination & Retry** - Automatic handling of large datasets and rate limits
- **OAuth 2.0** - Secure authentication via browser

## Installation

```bash
npm install -g @drkraft/basecamp-cli
```

Or with bun:

```bash
bun add -g @drkraft/basecamp-cli
```

## Requirements

- Node.js >= 20

## Quick Start

### 1. Create a Basecamp Integration

1. Go to [Basecamp Integrations](https://launchpad.37signals.com/integrations)
2. Click "Register another application"
3. Fill in the details:
   - **Name**: Your app name
   - **Company**: Your company
   - **Website**: Your website
   - **Redirect URI**: `http://localhost:9292/callback`
4. Note your **Client ID** and **Client Secret**

### 2. Configure Credentials

```bash
export BASECAMP_CLIENT_ID="your-client-id"
export BASECAMP_CLIENT_SECRET="your-client-secret"
```

Or configure via CLI:

```bash
basecamp auth configure --client-id "your-client-id"
```

### 3. Login

```bash
basecamp auth login
```

This opens your browser for OAuth authentication.

## CLI Reference

### Authentication

```bash
basecamp auth login          # Login via OAuth
basecamp auth status         # Check auth status
basecamp auth logout         # Logout
```

### Accounts

```bash
basecamp accounts            # List available accounts
basecamp account set <id>    # Set current account
basecamp account current     # Show current account
```

### Projects

```bash
basecamp projects list                                    # List all projects
basecamp projects get <id>                                # Get project details
basecamp projects create --name "Project" --description "Desc"  # Create project
basecamp projects archive <id>                            # Archive project
```

### To-do Lists & To-dos

```bash
# To-do lists
basecamp todolists list --project <id>
basecamp todolists create --project <id> --name "Tasks"
basecamp todolists delete <id> --project <project-id>

# To-dos
basecamp todos list --project <id> --list <list-id>
basecamp todos list --project <id> --list <list-id> --completed
basecamp todos get <id> --project <project-id>
basecamp todos create --project <id> --list <list-id> --content "Task"
basecamp todos create --project <id> --list <list-id> --content "Task" \
  --due "2025-12-31" --assignees "123,456"
basecamp todos update <id> --project <project-id> --content "Updated"
basecamp todos complete <id> --project <project-id>
basecamp todos uncomplete <id> --project <project-id>
basecamp todos delete <id> --project <project-id>
basecamp todos move <id> --project <project-id> --list <target-list-id>

# To-do groups
basecamp todogroups list --project <id>
basecamp todogroups create --project <id> --name "Sprint 1"
```

### Messages

```bash
basecamp messages list --project <id>
basecamp messages get <id> --project <project-id>
basecamp messages create --project <id> --subject "Subject" --content "<p>HTML</p>"
```

### Campfires (Chat)

```bash
basecamp campfires list --project <id>
basecamp campfires lines --project <id> --campfire <campfire-id>
basecamp campfires send --project <id> --campfire <campfire-id> --message "Hello!"
```

### Comments

```bash
basecamp comments list --project <id> --recording <recording-id>
basecamp comments get <id> --project <project-id>
basecamp comments create --project <id> --recording <recording-id> --content "<p>Comment</p>"
basecamp comments update <id> --project <project-id> --content "<p>Updated</p>"
basecamp comments delete <id> --project <project-id>
```

### Documents & Vaults

```bash
# Vaults (folders)
basecamp vaults list --project <id>
basecamp vaults get <id> --project <project-id>
basecamp vaults create --project <id> --vault <parent-vault-id> --title "Folder Name"

# Documents
basecamp documents list --project <id> --vault <vault-id>
basecamp documents get <id> --project <project-id>
basecamp documents create --project <id> --vault <vault-id> --title "Doc" --content "<p>...</p>"
basecamp documents update <id> --project <project-id> --title "New Title"

# Uploads
basecamp uploads list --project <id> --vault <vault-id>
basecamp uploads get <id> --project <project-id>
```

### Schedules

```bash
basecamp schedules get --project <id>
basecamp schedules entries --project <id>
basecamp schedules entries --project <id> --status upcoming
basecamp schedules create-entry --project <id> --summary "Meeting" \
  --starts-at "2025-02-15T10:00:00" --ends-at "2025-02-15T11:00:00"
basecamp schedules update-entry <id> --project <project-id> --summary "Updated"
basecamp schedules delete-entry <id> --project <project-id>
```

### Card Tables (Kanban)

```bash
basecamp cardtables get --project <id>
basecamp cardtables columns --project <id>
basecamp cardtables create-column --project <id> --title "In Progress"
basecamp cardtables cards --project <id> --column <column-id>
basecamp cardtables create-card --project <id> --column <column-id> --title "Card"
basecamp cardtables move-card <card-id> --project <id> --column <new-column-id>
```

### Webhooks

```bash
basecamp webhooks list --project <id>
basecamp webhooks get <id> --project <project-id>
basecamp webhooks create --project <id> --payload-url "https://..."
basecamp webhooks update <id> --project <project-id> --active false
basecamp webhooks delete <id> --project <project-id>
```

### Recordings & Events

```bash
# Recordings (cross-project content)
basecamp recordings list --type Todo
basecamp recordings list --type Message --status archived
basecamp recordings archive <id> --project <project-id>
basecamp recordings restore <id> --project <project-id>
basecamp recordings trash <id> --project <project-id>

# Events (activity feed)
basecamp events list --project <id> --recording <recording-id>
```

### Subscriptions

```bash
basecamp subscriptions list --project <id> --recording <recording-id>
basecamp subscriptions subscribe --project <id> --recording <recording-id>
basecamp subscriptions unsubscribe --project <id> --recording <recording-id>
```

### Search

```bash
basecamp search "keyword"
basecamp search "keyword" --type Todo
basecamp search "keyword" --project <id>
```

### People

```bash
basecamp people list
basecamp people list --project <id>
basecamp people get <id>
basecamp people me
```

## Output Formats

All commands support `--format` flag:

```bash
basecamp projects list --format table   # Default, human-readable
basecamp projects list --format json    # JSON for scripting
```

## Global Options

```bash
basecamp --verbose projects list   # Enable debug output
basecamp -v people me              # Short form
```

## MCP Server

The CLI includes an MCP (Model Context Protocol) server for AI assistant integration.

### Starting the Server

```bash
basecamp-mcp
# Or
bun run mcp
```

### Available Tools (76)

| Category | Tools |
|----------|-------|
| Projects | `basecamp_list_projects`, `basecamp_get_project`, `basecamp_create_project`, `basecamp_archive_project` |
| Todo Lists | `basecamp_list_todolists`, `basecamp_get_todolist`, `basecamp_create_todolist`, `basecamp_delete_todolist` |
| Todo Groups | `basecamp_list_todolist_groups`, `basecamp_create_todolist_group` |
| Todos | `basecamp_list_todos`, `basecamp_get_todo`, `basecamp_create_todo`, `basecamp_update_todo`, `basecamp_complete_todo`, `basecamp_uncomplete_todo`, `basecamp_delete_todo`, `basecamp_move_todo` |
| Messages | `basecamp_list_messages`, `basecamp_get_message`, `basecamp_create_message` |
| People | `basecamp_list_people`, `basecamp_get_person`, `basecamp_get_me` |
| Comments | `basecamp_list_comments`, `basecamp_get_comment`, `basecamp_create_comment`, `basecamp_update_comment`, `basecamp_delete_comment` |
| Vaults | `basecamp_list_vaults`, `basecamp_get_vault`, `basecamp_create_vault`, `basecamp_update_vault` |
| Documents | `basecamp_list_documents`, `basecamp_get_document`, `basecamp_create_document`, `basecamp_update_document` |
| Uploads | `basecamp_list_uploads`, `basecamp_get_upload`, `basecamp_create_upload`, `basecamp_update_upload` |
| Schedules | `basecamp_get_schedule`, `basecamp_list_schedule_entries`, `basecamp_get_schedule_entry`, `basecamp_create_schedule_entry`, `basecamp_update_schedule_entry`, `basecamp_delete_schedule_entry` |
| Card Tables | `basecamp_get_card_table`, `basecamp_get_column`, `basecamp_create_column`, `basecamp_update_column`, `basecamp_delete_column`, `basecamp_list_cards`, `basecamp_get_card`, `basecamp_create_card`, `basecamp_update_card`, `basecamp_move_card`, `basecamp_delete_card` |
| Search | `basecamp_search` |
| Recordings | `basecamp_list_recordings`, `basecamp_archive_recording`, `basecamp_restore_recording`, `basecamp_trash_recording` |
| Subscriptions | `basecamp_list_subscriptions`, `basecamp_subscribe`, `basecamp_unsubscribe` |
| Webhooks | `basecamp_list_webhooks`, `basecamp_get_webhook`, `basecamp_create_webhook`, `basecamp_update_webhook`, `basecamp_delete_webhook`, `basecamp_test_webhook` |
| Events | `basecamp_list_events` |
| Campfires | `basecamp_list_campfires`, `basecamp_get_campfire_lines`, `basecamp_send_campfire_line` |

### Using with OpenCode/Claude

Add to your MCP configuration:

```json
{
  "mcpServers": {
    "basecamp": {
      "command": "basecamp-mcp",
      "env": {
        "BASECAMP_CLIENT_ID": "<your-client-id>",
        "BASECAMP_CLIENT_SECRET": "<your-client-secret>"
      }
    }
  }
}
```

Or with explicit path:

```json
{
  "mcpServers": {
    "basecamp": {
      "command": "node",
      "args": ["/path/to/node_modules/@drkraft/basecamp-cli/dist/mcp.js"]
    }
  }
}
```

## Environment Variables

| Variable | Description |
|----------|-------------|
| `BASECAMP_CLIENT_ID` | OAuth Client ID (required) |
| `BASECAMP_CLIENT_SECRET` | OAuth Client Secret (required) |
| `BASECAMP_REDIRECT_URI` | OAuth Redirect URI (default: `http://localhost:9292/callback`) |

## API Coverage

This CLI covers the complete Basecamp 4 API Tier 1 domains:

| Domain | Status |
|--------|--------|
| Projects | Complete |
| Todolists | Complete |
| Todos | Complete |
| Todolist Groups | Complete |
| Messages | Complete |
| Campfires | Complete |
| Comments | Complete |
| Vaults | Complete |
| Documents | Complete |
| Uploads | Complete |
| Schedules | Complete |
| Card Tables | Complete |
| Webhooks | Complete |
| Recordings | Complete |
| Events | Complete |
| Search | Complete |
| Subscriptions | Complete |
| People | Complete |

## Development

```bash
# Clone the repo
git clone https://github.com/drkraft/basecamp-cli
cd basecamp-cli

# Install dependencies
bun install

# Build
bun run build

# Run tests
bun test

# Run validation against real Basecamp
bun run scripts/validate.ts
```

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

## License

MIT - see [LICENSE](LICENSE) for details.

## Credits

Originally forked from [@emredoganer/basecamp-cli](https://github.com/emredoganer/basecamp-cli).


path: package.json
content: {
  "name": "@drkraft/basecamp-cli",
  "version": "2.0.0",
  "description": "Complete CLI and MCP server for Basecamp 4 - projects, todos, messages, kanban, schedules, and more",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist",
    "README.md",
    "SKILL.md"
  ],
  "publishConfig": {
    "access": "public"
  },
  "bin": {
    "basecamp": "dist/index.js",
    "basecamp-mcp": "dist/mcp.js"
  },
  "type": "module",
  "scripts": {
    "build": "tsup",
    "dev": "tsup src/index.ts --format esm --watch",
    "mcp": "bun run src/mcp.ts",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "prepublishOnly": "npm run build"
  },
  "keywords": [
    "basecamp",
    "cli",
    "mcp",
    "ai",
    "project-management",
    "todos",
    "37signals"
  ],
  "homepage": "https://github.com/drkraft/basecamp-cli#readme",
  "bugs": "https://github.com/drkraft/basecamp-cli/issues",
  "author": "drkraft",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/drkraft/basecamp-cli.git"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.25.3",
    "chalk": "^5.3.0",
    "cli-table3": "^0.6.5",
    "commander": "^12.1.0",
    "conf": "^13.0.1",
    "got": "^14.4.5",
    "open": "^10.1.0",
    "zod": "^4.3.6"
  },
  "devDependencies": {
    "@types/node": "^22.10.5",
    "@vitest/coverage-v8": "^4.0.18",
    "msw": "^2.12.7",
    "tsup": "^8.3.5",
    "typescript": "^5.7.2",
    "vitest": "^4.0.18"
  },
  "engines": {
    "node": ">=20.0.0"
  }
}


path: scripts/release.sh
content: #!/bin/bash
set -e

echo "=== Basecamp CLI v2.0.0 Release ==="
echo ""

# Check prerequisites
echo "Checking prerequisites..."

if ! command -v npm &>/dev/null; then
	echo "ERROR: npm not found"
	exit 1
fi

if ! command -v gh &>/dev/null; then
	echo "WARNING: gh (GitHub CLI) not found - manual release creation needed"
fi

# Verify build
echo "Building..."
bun run build

# Verify tests (if configured)
echo "Running tests..."
bun test || echo "Tests skipped or failed - review before publishing"

# Show package contents
echo ""
echo "Package contents:"
npm pack --dry-run

# Confirm
echo ""
read -p "Ready to publish? (y/N) " -n 1 -r
echo ""

if [[ ! $REPLY =~ ^[Yy]$ ]]; then
	echo "Aborted."
	exit 1
fi

# Push to GitHub
echo ""
echo "Pushing to GitHub..."
git push origin main

# Publish to npm
echo ""
echo "Publishing to npm..."
npm publish --access public

# Create GitHub release
echo ""
echo "Creating GitHub release..."
if command -v gh &>/dev/null; then
	gh release create v2.0.0 --title "v2.0.0" --notes-file CHANGELOG.md
else
	echo "Create release manually at: https://github.com/drkraft/basecamp-cli/releases/new"
fi

echo ""
echo "=== Release complete! ==="
echo ""
echo "Next steps:"
echo "1. Verify on npm: https://www.npmjs.com/package/@drkraft/basecamp-cli"
echo "2. Verify on GitHub: https://github.com/drkraft/basecamp-cli"
echo "3. Submit to ClawHub (manual)"


path: scripts/validate.ts
content: #!/usr/bin/env bun
/**
 * Manual Validation Script for Basecamp CLI
 * 
 * This script runs through all CLI commands against a real Basecamp account
 * to verify everything works correctly before release.
 * 
 * Prerequisites:
 * - BASECAMP_CLIENT_ID and BASECAMP_CLIENT_SECRET environment variables set
 * - Authenticated via `basecamp auth login`
 * - At least one project in Basecamp with todos, messages, etc.
 * 
 * Usage:
 *   bun run scripts/validate.ts
 *   bun run scripts/validate.ts --project <PROJECT_ID>
 */

import { execSync } from 'child_process';

const COLORS = {
  reset: '\x1b[0m',
  green: '\x1b[32m',
  red: '\x1b[31m',
  yellow: '\x1b[33m',
  blue: '\x1b[34m',
  gray: '\x1b[90m',
};

interface TestResult {
  name: string;
  command: string;
  passed: boolean;
  output?: string;
  error?: string;
}

const results: TestResult[] = [];

function log(color: string, prefix: string, message: string): void {
  console.log(`${color}${prefix}${COLORS.reset} ${message}`);
}

function run(name: string, command: string): TestResult {
  log(COLORS.blue, '[RUN]', `${name}: ${COLORS.gray}${command}`);
  try {
    const output = execSync(command, {
      encoding: 'utf-8',
      timeout: 60000,
      maxBuffer: 50 * 1024 * 1024,
      stdio: ['pipe', 'pipe', 'pipe'],
    });
    log(COLORS.green, '[PASS]', name);
    return { name, command, passed: true, output: output.trim() };
  } catch (error) {
    const err = error as { stderr?: Buffer; message?: string };
    const errorMsg = err.stderr?.toString() || err.message || 'Unknown error';
    log(COLORS.red, '[FAIL]', `${name}: ${errorMsg.slice(0, 100)}`);
    return { name, command, passed: false, error: errorMsg };
  }
}

function section(title: string): void {
  console.log(`\n${COLORS.yellow}=== ${title} ===${COLORS.reset}\n`);
}

async function main(): Promise<void> {
  console.log(`${COLORS.blue}
╔══════════════════════════════════════════════════╗
║       Basecamp CLI Manual Validation Script       ║
╚══════════════════════════════════════════════════╝
${COLORS.reset}`);

  // Parse arguments
  const args = process.argv.slice(2);
  const projectIdIndex = args.indexOf('--project');
  let projectId = projectIdIndex !== -1 ? args[projectIdIndex + 1] : null;

  const CLI = './dist/index.js';

  // ============ AUTH ============
  section('Authentication');
  results.push(run('Auth status', `${CLI} auth status`));

  // ============ ACCOUNTS ============
  section('Accounts');
  results.push(run('List accounts', `${CLI} accounts`));
  results.push(run('Current account', `${CLI} account current`));

  // ============ PROJECTS ============
  section('Projects');
  const projectsResult = run('List projects', `${CLI} projects list --format json`);
  results.push(projectsResult);

  // Extract first project ID if not provided
  if (!projectId && projectsResult.passed && projectsResult.output) {
    try {
      const projects = JSON.parse(projectsResult.output);
      if (Array.isArray(projects) && projects.length > 0) {
        projectId = String(projects[0].id);
        log(COLORS.blue, '[INFO]', `Using project ID: ${projectId}`);
      }
    } catch {
      log(COLORS.yellow, '[WARN]', 'Could not parse projects output');
    }
  }

  if (!projectId) {
    log(COLORS.red, '[ERROR]', 'No project ID available. Create a project or specify --project <ID>');
    printSummary();
    process.exit(1);
  }

  results.push(run('Get project', `${CLI} projects get ${projectId} --format json`));

  // ============ PEOPLE ============
  section('People');
  results.push(run('Get me', `${CLI} people me --format json`));
  results.push(run('List people', `${CLI} people list --format json`));
  results.push(run('List project people', `${CLI} people list --project ${projectId} --format json`));

  // ============ TODO LISTS ============
  section('Todo Lists');
  const todolistsResult = run('List todolists', `${CLI} todolists list --project ${projectId} --format json`);
  results.push(todolistsResult);

  let todolistId: string | null = null;
  if (todolistsResult.passed && todolistsResult.output) {
    try {
      const todolists = JSON.parse(todolistsResult.output);
      if (Array.isArray(todolists) && todolists.length > 0) {
        todolistId = String(todolists[0].id);
        log(COLORS.blue, '[INFO]', `Using todolist ID: ${todolistId}`);
      }
    } catch {
      log(COLORS.yellow, '[WARN]', 'Could not parse todolists output');
    }
  }

  // ============ TODOS ============
  section('Todos');
  if (todolistId) {
    results.push(run('List todos', `${CLI} todos list --project ${projectId} --list ${todolistId} --format json`));
    // Create a test todo
    const createTodoResult = run(
      'Create todo',
      `${CLI} todos create --project ${projectId} --list ${todolistId} --content "Test todo from validation script" --format json`
    );
    results.push(createTodoResult);

    if (createTodoResult.passed && createTodoResult.output) {
      try {
        const todo = JSON.parse(createTodoResult.output);
        const todoId = todo.id;
        if (todoId) {
          results.push(run('Get todo', `${CLI} todos get ${todoId} --project ${projectId} --format json`));
          results.push(run('Complete todo', `${CLI} todos complete ${todoId} --project ${projectId}`));
          results.push(run('Uncomplete todo', `${CLI} todos uncomplete ${todoId} --project ${projectId}`));
        }
      } catch {
        log(COLORS.yellow, '[WARN]', 'Could not parse created todo');
      }
    }
  } else {
    log(COLORS.yellow, '[SKIP]', 'No todolist found, skipping todo tests');
  }

  // ============ TODO GROUPS ============
  section('Todo Groups');
  if (todolistId) {
    results.push(run('List todo groups', `${CLI} todogroups list --project ${projectId} --list ${todolistId} --format json`));
  } else {
    log(COLORS.yellow, '[SKIP]', 'No todolist found, skipping todo groups tests');
  }

  // ============ MESSAGES ============
  section('Messages');
  results.push(run('List messages', `${CLI} messages list --project ${projectId} --format json`));

  // ============ CAMPFIRES ============
  section('Campfires');
  results.push(run('List campfires', `${CLI} campfires list --project ${projectId} --format json`));

  // ============ VAULTS ============
  section('Vaults (Docs & Files)');
  const vaultsResult = run('List vaults', `${CLI} vaults list --project ${projectId} --format json`);
  results.push(vaultsResult);

  // ============ DOCUMENTS ============
  section('Documents');
  // Documents require a vault ID - skip if no vault
  log(COLORS.gray, '[INFO]', 'Document tests require manual vault ID');

  // ============ SCHEDULES ============
  section('Schedules');
  results.push(run('Get schedule', `${CLI} schedules get --project ${projectId} --format json`));
  results.push(run('List schedule entries', `${CLI} schedules entries --project ${projectId} --format json`));

  // ============ CARD TABLES ============
  section('Card Tables (Kanban)');
  results.push(run('Get card table', `${CLI} cardtables get --project ${projectId} --format json`));

  // ============ WEBHOOKS ============
  section('Webhooks');
  results.push(run('List webhooks', `${CLI} webhooks list --project ${projectId} --format json`));

  // ============ RECORDINGS ============
  section('Recordings');
  results.push(run('List recordings (Todo)', `${CLI} recordings list --type Todo --bucket ${projectId} --format json`));

  // ============ EVENTS ============
  section('Events');
  log(COLORS.gray, '[INFO]', 'Events require a recording ID - skipping (tested via MCP)');

  // ============ SUBSCRIPTIONS ============
  section('Subscriptions');
  log(COLORS.gray, '[INFO]', 'Subscription tests require a recording ID');

  // ============ SEARCH ============
  section('Search');
  results.push(run('Search', `${CLI} search "test" --format json`));

  // ============ MCP SERVER ============
  section('MCP Server');
  results.push(run('MCP tools/list', `echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | bun run mcp | grep -q '"tools"' && echo "MCP OK"`));

  // ============ SUMMARY ============
  printSummary();
}

function printSummary(): void {
  console.log(`\n${COLORS.blue}
╔══════════════════════════════════════════════════╗
║                    SUMMARY                        ║
╚══════════════════════════════════════════════════╝
${COLORS.reset}`);

  const passed = results.filter((r) => r.passed).length;
  const failed = results.filter((r) => !r.passed).length;
  const total = results.length;

  console.log(`Total: ${total}`);
  console.log(`${COLORS.green}Passed: ${passed}${COLORS.reset}`);
  console.log(`${COLORS.red}Failed: ${failed}${COLORS.reset}`);

  if (failed > 0) {
    console.log(`\n${COLORS.red}Failed tests:${COLORS.reset}`);
    results
      .filter((r) => !r.passed)
      .forEach((r) => {
        console.log(`  - ${r.name}`);
        if (r.error) {
          console.log(`    ${COLORS.gray}${r.error.slice(0, 200)}${COLORS.reset}`);
        }
      });
  }

  console.log(`\nPass rate: ${((passed / total) * 100).toFixed(1)}%`);
  process.exit(failed > 0 ? 1 : 0);
}

main().catch(console.error);


path: src/__tests__/cardtables.test.ts
content: import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
  getCardTable,
  getColumn,
  createColumn,
  updateColumn,
  deleteColumn,
  listCards,
  getCard,
  createCard,
  updateCard,
  moveCard,
  deleteCard
} from '../lib/api.js';

vi.mock('../lib/auth.js', () => ({
  getValidAccessToken: vi.fn().mockResolvedValue('test-token')
}));

vi.mock('../lib/config.js', () => ({
  getCurrentAccountId: vi.fn().mockReturnValue(123456)
}));

const mockProject = {
  id: 2085958499,
  status: 'active',
  created_at: '2022-11-18T09:50:54.566Z',
  updated_at: '2022-11-18T09:50:54.760Z',
  name: 'Test Project',
  description: 'Test Description',
  purpose: 'topic',
  clients_enabled: false,
  bookmark_url: 'https://3.basecampapi.com/test',
  url: 'https://3.basecampapi.com/195539477/projects/2085958499.json',
  app_url: 'https://3.basecamp.com/195539477/projects/2085958499',
  dock: [
    {
      id: 1069482091,
      title: 'Card Table',
      name: 'kanban_board',
      enabled: true,
      position: 1,
      url: 'https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/1069482091.json',
      app_url: 'https://3.basecamp.com/195539477/buckets/2085958499/card_tables/1069482091'
    }
  ],
  bookmarked: false
};

const mockCardTable = {
  id: 1069482091,
  status: 'active',
  visible_to_clients: false,
  created_at: '2022-11-18T09:51:27.237Z',
  updated_at: '2022-11-18T09:51:41.811Z',
  title: 'Card Table',
  inherits_status: true,
  type: 'Kanban::Board',
  url: 'https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/1069482091.json',
  app_url: 'https://3.basecamp.com/195539477/buckets/2085958499/card_tables/1069482091',
  bookmark_url: 'https://3.basecampapi.com/test',
  subscription_url: 'https://3.basecampapi.com/test',
  bucket: {
    id: 2085958499,
    name: 'Test Project',
    type: 'Project'
  },
  creator: {
    id: 1049716070,
    attachable_sgid: 'test-sgid',
    name: 'Victor Cooper',
    email_address: '[REDACTED_SECRET]',
    personable_type: 'User',
    title: 'Chief Strategist',
    bio: 'Test bio',
    location: 'Chicago, IL',
    created_at: '2022-11-18T09:50:54.566Z',
    updated_at: '2022-11-18T09:50:54.760Z',
    admin: true,
    owner: true,
    client: false,
    employee: true,
    time_zone: 'America/Chicago',
    avatar_url: 'https://example.com/avatar.jpg',
    can_manage_projects: true,
    can_manage_people: true
  },
  subscribers: [],
  lists: [
    {
      id: 1069482092,
      status: 'active',
      visible_to_clients: false,
      created_at: '2022-11-18T09:51:27.242Z',
      updated_at: '2022-11-18T09:51:41.806Z',
      title: 'Triage',
      inherits_status: true,
      type: 'Kanban::Triage',
      url: 'https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/columns/1069482092.json',
      app_url: 'https://3.basecamp.com/195539477/buckets/2085958499/card_tables/columns/1069482092',
      bookmark_url: 'https://3.basecampapi.com/test',
      parent: {
        id: 1069482091,
        title: 'Card Table',
        type: 'Kanban::Board',
        url: 'https://3.basecampapi.com/test',
        app_url: 'https://3.basecamp.com/test'
      },
      bucket: {
        id: 2085958499,
        name: 'Test Project',
        type: 'Project'
      },
      creator: {
        id: 1049716070,
        attachable_sgid: 'test-sgid',
        name: 'Victor Cooper',
        email_address: '[REDACTED_SECRET]',
        personable_type: 'User',
        title: 'Chief Strategist',
        bio: 'Test bio',
        location: 'Chicago, IL',
        created_at: '2022-11-18T09:50:54.566Z',
        updated_at: '2022-11-18T09:50:54.760Z',
        admin: true,
        owner: true,
        client: false,
        employee: true,
        time_zone: 'America/Chicago',
        avatar_url: 'https://example.com/avatar.jpg',
        can_manage_projects: true,
        can_manage_people: true
      },
      description: null,
      subscribers: [],
      color: null,
      cards_count: 1,
      comment_count: 0,
      cards_url: 'https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/lists/1069482092/cards.json'
    }
  ]
};

const mockColumn = mockCardTable.lists[0];

const mockCard = {
  id: 1069482295,
  status: 'active',
  visible_to_clients: false,
  created_at: '2022-11-18T13:42:27.150Z',
  updated_at: '2022-11-18T13:42:27.150Z',
  title: 'New and fancy UI',
  inherits_status: true,
  type: 'Kanban::Card',
  url: 'https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069482295.json',
  app_url: 'https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069482295',
  bookmark_url: 'https://3.basecampapi.com/test',
  subscription_url: 'https://3.basecampapi.com/test',
  comments_count: 0,
  comments_url: 'https://3.basecampapi.com/test',
  position: 1,
  parent: {
    id: 1069482092,
    title: 'Triage',
    type: 'Kanban::Triage',
    url: 'https://3.basecampapi.com/test',
    app_url: 'https://3.basecamp.com/test'
  },
  bucket: {
    id: 2085958499,
    name: 'Test Project',
    type: 'Project'
  },
  creator: {
    id: 1049716070,
    attachable_sgid: 'test-sgid',
    name: 'Victor Cooper',
    email_address: '[REDACTED_SECRET]',
    personable_type: 'User',
    title: 'Chief Strategist',
    bio: 'Test bio',
    location: 'Chicago, IL',
    created_at: '2022-11-18T09:50:54.566Z',
    updated_at: '2022-11-18T09:50:54.760Z',
    admin: true,
    owner: true,
    client: false,
    employee: true,
    time_zone: 'America/Chicago',
    avatar_url: 'https://example.com/avatar.jpg',
    can_manage_projects: true,
    can_manage_people: true
  },
  description: 'Design a new and fancy UI',
  completed: false,
  content: 'Design a new and fancy UI',
  due_on: null,
  assignees: [],
  completion_subscribers: [],
  completion_url: 'https://3.basecampapi.com/test',
  comment_count: 0
};

describe('Card Tables API', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('getCardTable', () => {
    it('should fetch card table for a project', async () => {
      const result = await getCardTable(2085958499);
      expect(result).toEqual(mockCardTable);
    });

    it('should throw error if card table not enabled', async () => {
      await expect(getCardTable(999)).rejects.toThrow(
        'Card table (Kanban board) not enabled for this project'
      );
    });
  });

  describe('getColumn', () => {
    it('should fetch a column', async () => {
      const result = await getColumn(2085958499, 1069482092);
      expect(result).toEqual(mockColumn);
    });
  });

  describe('createColumn', () => {
    it('should create a column', async () => {
      const result = await createColumn(2085958499, 'New Column', 'Description');
      expect(result).toEqual(mockColumn);
    });
  });

  describe('updateColumn', () => {
    it('should update a column', async () => {
      const updatedColumn = { ...mockColumn, title: 'Updated Title' };
      const result = await updateColumn(2085958499, 1069482092, { title: 'Updated Title' });
      expect(result).toEqual(updatedColumn);
    });
  });

  describe('deleteColumn', () => {
    it('should delete a column', async () => {
      await expect(deleteColumn(2085958499, 1069482092)).resolves.toBeUndefined();
    });
  });

  describe('listCards', () => {
    it('should list cards in a column', async () => {
      const result = await listCards(2085958499, 1069482092);
      expect(result).toEqual([mockCard]);
    });
  });

  describe('getCard', () => {
    it('should fetch a card', async () => {
      const result = await getCard(2085958499, 1069482295);
      expect(result).toEqual(mockCard);
    });
  });

  describe('createCard', () => {
    it('should create a card', async () => {
      const result = await createCard(2085958499, 1069482092, 'New Card', {
        content: 'Card content',
        due_on: '2024-12-31'
      });
      expect(result).toEqual(mockCard);
    });
  });

  describe('updateCard', () => {
    it('should update a card', async () => {
      const updatedCard = { ...mockCard, title: 'Updated Card' };
      const result = await updateCard(2085958499, 1069482295, { title: 'Updated Card' });
      expect(result).toEqual(updatedCard);
    });
  });

  describe('moveCard', () => {
    it('should move a card to another column', async () => {
      await expect(moveCard(2085958499, 1069482295, 1069482093)).resolves.toBeUndefined();
    });
  });

  describe('deleteCard', () => {
    it('should delete a card', async () => {
      await expect(deleteCard(2085958499, 1069482295)).resolves.toBeUndefined();
    });
  });
});


path: src/__tests__/comments.test.ts
content: import { describe, it, expect } from 'vitest';
import * as api from '../lib/api.js';

describe('Comments API Functions', () => {
  describe('Function exports', () => {
    it('should export listComments function', () => {
      expect(typeof api.listComments).toBe('function');
    });

    it('should export getComment function', () => {
      expect(typeof api.getComment).toBe('function');
    });

    it('should export createComment function', () => {
      expect(typeof api.createComment).toBe('function');
    });

    it('should export updateComment function', () => {
      expect(typeof api.updateComment).toBe('function');
    });

    it('should export deleteComment function', () => {
      expect(typeof api.deleteComment).toBe('function');
    });
  });

  describe('Function signatures', () => {
    it('listComments should accept projectId and recordingId', () => {
      const fn = api.listComments;
      expect(fn.length).toBe(2);
    });

    it('getComment should accept projectId and commentId', () => {
      const fn = api.getComment;
      expect(fn.length).toBe(2);
    });

    it('createComment should accept projectId, recordingId, and content', () => {
      const fn = api.createComment;
      expect(fn.length).toBe(3);
    });

    it('updateComment should accept projectId, commentId, and content', () => {
      const fn = api.updateComment;
      expect(fn.length).toBe(3);
    });

    it('deleteComment should accept projectId and commentId', () => {
      const fn = api.deleteComment;
      expect(fn.length).toBe(2);
    });
  });

  describe('Function return types', () => {
    it('listComments should return a Promise', async () => {
      const result = await api.listComments(1, 1);
      expect(Array.isArray(result)).toBe(true);
      expect(result[0]?.id).toBe(1);
    });

    it('getComment should return a Promise', async () => {
      const result = await api.getComment(1, 1);
      expect(result.id).toBe(1);
    });

    it('createComment should return a Promise', async () => {
      const result = await api.createComment(1, 1, 'test');
      expect(result.id).toBe(2);
    });

    it('updateComment should return a Promise', async () => {
      const result = await api.updateComment(1, 1, 'test');
      expect(result.id).toBe(1);
    });

    it('deleteComment should return a Promise', async () => {
      await expect(api.deleteComment(1, 1)).resolves.toBeUndefined();
    });
  });
});


path: src/__tests__/config.test.ts
content: import { describe, it, expect, beforeAll, beforeEach, vi, afterEach } from 'vitest';
import crypto from 'crypto';
import os from 'os';
import type { BasecampTokens } from '../types';

let config: typeof import('../lib/config.js');

beforeAll(async () => {
  vi.unmock('../lib/config.js');
  config = await import('../lib/config.js');
});

describe('Config Module', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    process.env.BASECAMP_CLIENT_SECRET = 'test-client-secret';
  });

  afterEach(() => {
    vi.clearAllMocks();
  });

  describe('Encryption Utilities', () => {
    it('should generate consistent encryption key for same machine', () => {
      const machineId = `${os.hostname()}-${os.userInfo().username}-basecamp-cli-tokens`;
      const key1 = crypto.createHash('sha256').update(machineId).digest();
      const key2 = crypto.createHash('sha256').update(machineId).digest();

      expect(key1.equals(key2)).toBe(true);
    });

    it('should encrypt and decrypt strings correctly', () => {
      const plaintext = 'test-access-token';
      const machineId = `${os.hostname()}-${os.userInfo().username}-basecamp-cli-tokens`;
      const key = crypto.createHash('sha256').update(machineId).digest();

      const iv = crypto.randomBytes(16);
      const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
      let encrypted = cipher.update(plaintext, 'utf8', 'hex');
      encrypted += cipher.final('hex');
      const encryptedString = iv.toString('hex') + ':' + encrypted;

      const [ivHex, encryptedData] = encryptedString.split(':');
      const decipher = crypto.createDecipheriv('aes-256-cbc', key, Buffer.from(ivHex, 'hex'));
      let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
      decrypted += decipher.final('utf8');

      expect(decrypted).toBe(plaintext);
    });

    it('should detect encrypted format correctly', () => {
      const encrypted = '0123456789abcdef0123456789abcdef:encrypteddata';
      const plaintext = 'plain-text-token';

      const isEncrypted = (text: string): boolean => {
        if (!text) return false;
        const parts = text.split(':');
        return parts.length === 2 && parts[0].length === 32 && /^[0-9a-f]+$/i.test(parts[0]);
      };

      expect(isEncrypted(encrypted)).toBe(true);
      expect(isEncrypted(plaintext)).toBe(false);
    });

    it('should handle decryption of invalid format gracefully', () => {
      const invalidEncrypted = 'invalid-format';
      const machineId = `${os.hostname()}-${os.userInfo().username}-basecamp-cli-tokens`;
      const key = crypto.createHash('sha256').update(machineId).digest();

      try {
        const [ivHex, encrypted] = invalidEncrypted.split(':');
        if (!ivHex || !encrypted) {
          throw new Error('Invalid encrypted format');
        }
      } catch (error) {
        expect(error).toBeDefined();
      }
    });
  });

  describe('Token Management', () => {
    describe('setTokens and getTokens', () => {
      it('should store and retrieve tokens', () => {
        const tokens: BasecampTokens = {
          access_token: 'test-access-token',
          refresh_token: 'test-refresh-token',
          expires_at: Date.now() + 3600000,
        };

        config.setTokens(tokens);
        const retrieved = config.getTokens();

        expect(retrieved).toBeDefined();
        expect(retrieved?.access_token).toBe(tokens.access_token);
        expect(retrieved?.refresh_token).toBe(tokens.refresh_token);
        expect(retrieved?.expires_at).toBe(tokens.expires_at);
      });

      it('should handle token lifecycle', () => {
        const tokens: BasecampTokens = {
          access_token: 'test-access-token',
          refresh_token: 'test-refresh-token',
          expires_at: Date.now() + 3600000,
        };

        config.setTokens(tokens);
        expect(config.getTokens()).toBeDefined();

        config.clearTokens();
        expect(config.getTokens()).toBeUndefined();
      });
    });
  });

  describe('Account Management', () => {
    it('should set and get current account ID', () => {
      config.setCurrentAccountId(99999999);
      const accountId = config.getCurrentAccountId();

      expect(accountId).toBe(99999999);
    });

    it('should handle different account IDs', () => {
      config.setCurrentAccountId(12345678);
      expect(config.getCurrentAccountId()).toBe(12345678);

      config.setCurrentAccountId(87654321);
      expect(config.getCurrentAccountId()).toBe(87654321);
    });
  });

  describe('Client Credentials', () => {
    it('should return credentials from env', () => {
      process.env.BASECAMP_CLIENT_SECRET = 'test-secret';
      process.env.BASECAMP_CLIENT_ID = 'test-client-id';

      const credentials = config.getClientCredentials();

      expect(credentials.clientSecret).toBe('test-secret');
      expect(credentials.clientId).toBeTruthy();
      expect(credentials.redirectUri).toBeTruthy();
    });

    it('should throw error when client secret is missing', () => {
      const originalSecret = process.env.BASECAMP_CLIENT_SECRET;
      delete process.env.BASECAMP_CLIENT_SECRET;

      expect(() => config.getClientCredentials()).toThrow(
        'BASECAMP_CLIENT_SECRET environment variable is not set'
      );

      process.env.BASECAMP_CLIENT_SECRET = originalSecret;
    });

    it('should use default redirect URI', () => {
      process.env.BASECAMP_CLIENT_SECRET = 'test-secret';

      const credentials = config.getClientCredentials();

      expect(credentials.redirectUri).toMatch(/^http/);
    });

    it('should store and retrieve client config', () => {
      config.setClientConfig('my-client-id', 'http://localhost:8080/callback');

      const credentials = config.getClientCredentials();
      expect(credentials.clientId).toBe('my-client-id');
    });
  });

  describe('Utility Functions', () => {
    describe('isAuthenticated', () => {
      it('should return true when tokens exist', () => {
        const tokens: BasecampTokens = {
          access_token: 'test-access-token',
          refresh_token: 'test-refresh-token',
          expires_at: Date.now() + 3600000,
        };

        config.setTokens(tokens);
        expect(config.isAuthenticated()).toBe(true);
      });

      it('should return false when no tokens', () => {
        config.clearTokens();
        expect(config.isAuthenticated()).toBe(false);
      });
    });

    describe('isTokenExpired', () => {
      it('should return false when token is valid', () => {
        const tokens: BasecampTokens = {
          access_token: 'test-access-token',
          refresh_token: 'test-refresh-token',
          expires_at: Date.now() + 3600000,
        };

        config.setTokens(tokens);
        expect(config.isTokenExpired()).toBe(false);
      });

      it('should return true when token is expired', () => {
        const tokens: BasecampTokens = {
          access_token: 'test-access-token',
          refresh_token: 'test-refresh-token',
          expires_at: Date.now() - 1000,
        };

        config.setTokens(tokens);
        expect(config.isTokenExpired()).toBe(true);
      });

      it('should return true when token expires within 1 minute', () => {
        const tokens: BasecampTokens = {
          access_token: 'test-access-token',
          refresh_token: 'test-refresh-token',
          expires_at: Date.now() + 30000,
        };

        config.setTokens(tokens);
        expect(config.isTokenExpired()).toBe(true);
      });

      it('should return true when no tokens exist', () => {
        config.clearTokens();
        expect(config.isTokenExpired()).toBe(true);
      });
    });
  });

  describe('Security', () => {
    it('should require client secret from environment', () => {
      process.env.BASECAMP_CLIENT_SECRET = 'test-secret';
      config.setClientConfig('test-client-id', 'http://localhost:9292/callback');

      const credentials = config.getClientCredentials();
      expect(credentials.clientSecret).toBe('test-secret');
    });

    it('should encrypt and decrypt tokens correctly', () => {
      const tokens: BasecampTokens = {
        access_token: 'plain-access-token',
        refresh_token: 'plain-refresh-token',
        expires_at: Date.now() + 3600000,
      };

      config.setTokens(tokens);
      const retrieved = config.getTokens();

      expect(retrieved?.access_token).toBe(tokens.access_token);
      expect(retrieved?.refresh_token).toBe(tokens.refresh_token);
    });

    it('should use machine-specific encryption key', () => {
      const machineId1 = `${os.hostname()}-${os.userInfo().username}-basecamp-cli-tokens`;
      const machineId2 = `different-host-different-user-basecamp-cli-tokens`;

      const key1 = crypto.createHash('sha256').update(machineId1).digest();
      const key2 = crypto.createHash('sha256').update(machineId2).digest();

      expect(key1.equals(key2)).toBe(false);
    });
  });

  describe('Edge Cases', () => {
    it('should handle very long token strings', () => {
      const longToken = 'a'.repeat(10000);
      const tokens: BasecampTokens = {
        access_token: longToken,
        refresh_token: longToken,
        expires_at: Date.now() + 3600000,
      };

      expect(() => config.setTokens(tokens)).not.toThrow();
      
      const retrieved = config.getTokens();
      expect(retrieved?.access_token).toBe(longToken);
    });

    it('should handle special characters in tokens', () => {
      const tokens: BasecampTokens = {
        access_token: 'token-with-special-chars-!@#$%^&*()',
        refresh_token: 'token-with-unicode-émojis-🎉',
        expires_at: Date.now() + 3600000,
      };

      expect(() => config.setTokens(tokens)).not.toThrow();
      
      const retrieved = config.getTokens();
      expect(retrieved?.access_token).toBe(tokens.access_token);
      expect(retrieved?.refresh_token).toBe(tokens.refresh_token);
    });
  });
});


path: src/__tests__/documents.test.ts
content: import { describe, it, expect } from 'vitest';
import * as api from '../lib/api.js';

describe('Documents API Functions', () => {
  describe('Function exports', () => {
    it('should export listDocuments function', () => {
      expect(typeof api.listDocuments).toBe('function');
    });

    it('should export getDocument function', () => {
      expect(typeof api.getDocument).toBe('function');
    });

    it('should export createDocument function', () => {
      expect(typeof api.createDocument).toBe('function');
    });

    it('should export updateDocument function', () => {
      expect(typeof api.updateDocument).toBe('function');
    });
  });

  describe('Function signatures', () => {
    it('listDocuments should accept projectId and vaultId', () => {
      const fn = api.listDocuments;
      expect(fn.length).toBe(2);
    });

    it('getDocument should accept projectId and documentId', () => {
      const fn = api.getDocument;
      expect(fn.length).toBe(2);
    });

    it('createDocument should accept projectId, vaultId, title, content, and optional status', () => {
      const fn = api.createDocument;
      expect(fn.length).toBe(5);
    });

    it('updateDocument should accept projectId, documentId, and updates', () => {
      const fn = api.updateDocument;
      expect(fn.length).toBe(3);
    });
  });

  describe('Function return types', () => {
    it('listDocuments should return a Promise', async () => {
      const result = await api.listDocuments(1, 1);
      expect(Array.isArray(result)).toBe(true);
      expect(result[0]?.id).toBe(5001);
    });

    it('getDocument should return a Promise', async () => {
      const result = await api.getDocument(1, 1);
      expect(result.id).toBe(5001);
    });

    it('createDocument should return a Promise', async () => {
      const result = await api.createDocument(1, 1, 'Test Doc', '<p>Content</p>');
      expect(result.title).toBe('Test Doc');
    });

    it('updateDocument should return a Promise', async () => {
      const result = await api.updateDocument(1, 1, { title: 'Updated' });
      expect(result.title).toBe('Updated');
    });
  });
});


path: src/__tests__/events.test.ts
content: import { describe, it, expect, vi, beforeEach } from 'vitest';
import { listEvents } from '../lib/api.js';

vi.mock('../lib/auth.js', () => ({
  getValidAccessToken: vi.fn().mockResolvedValue('test-token')
}));

vi.mock('../lib/config.js', () => ({
  getCurrentAccountId: vi.fn().mockReturnValue(123456)
}));

describe('Events API', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('listEvents', () => {
    it('should list events for a recording', async () => {
      const events = await listEvents(1, 100);
      expect(events).toBeDefined();
      expect(Array.isArray(events)).toBe(true);
    });

    it('should handle empty event list', async () => {
      const events = await listEvents(1, 999);
      expect(Array.isArray(events)).toBe(true);
    });

    it('should include event details', async () => {
      const events = await listEvents(1, 100);
      if (events.length > 0) {
        const event = events[0];
        expect(event).toHaveProperty('id');
        expect(event).toHaveProperty('recording_id');
        expect(event).toHaveProperty('action');
        expect(event).toHaveProperty('created_at');
        expect(event).toHaveProperty('creator');
      }
    });

    it('should handle API errors', async () => {
      await expect(listEvents(999, 999)).rejects.toThrow();
    });
  });
});


path: src/__tests__/example.test.ts
content: import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from './setup';

describe('Test Infrastructure', () => {
  it('should have MSW server running', () => {
    expect(server).toBeDefined();
  });

  it('should mock Basecamp API endpoints', async () => {
    const response = await fetch(
      'https://3.basecampapi.com/99999999/projects.json'
    );
    const data = await response.json();

    expect(response.ok).toBe(true);
    expect(Array.isArray(data)).toBe(true);
    expect(data[0]).toHaveProperty('id');
    expect(data[0]).toHaveProperty('name');
  });

  it('should allow handler overrides in tests', async () => {
    server.use(
      http.get('https://3.basecampapi.com/99999999/projects.json', () => {
        return HttpResponse.json([
          {
            id: 999,
            name: 'Override Project',
            description: 'Overridden for this test',
            status: 'active',
            created_at: '2024-01-01T00:00:00Z',
            updated_at: '2024-01-01T00:00:00Z',
          },
        ]);
      })
    );

    const response = await fetch(
      'https://3.basecampapi.com/99999999/projects.json'
    );
    const data = await response.json();

    expect(data[0].id).toBe(999);
    expect(data[0].name).toBe('Override Project');
  });

  it('should have environment variables set', () => {
    expect(process.env.BASECAMP_CLIENT_ID).toBe('test-client-id');
    expect(process.env.BASECAMP_CLIENT_SECRET).toBe('test-client-secret');
    expect(process.env.BASECAMP_ACCESS_TOKEN).toBe('test-access-token');
  });
});


path: src/__tests__/mcp.test.ts
content: import { describe, it, expect } from 'vitest';
import { getTools, executeTool } from '../mcp/tools/index.js';

describe('MCP Tools', () => {
  it('should list available tools', async () => {
    const tools = await getTools();
    expect(Array.isArray(tools)).toBe(true);
    expect(tools.some((tool) => tool.name === 'basecamp_list_projects')).toBe(true);
  });

  it('should execute a tool call', async () => {
    const result = await executeTool('basecamp_list_projects', {});
    expect(Array.isArray(result)).toBe(true);
  });

  it('should reject unknown tools', async () => {
    await expect(executeTool('basecamp_unknown_tool', {})).rejects.toThrow('Unknown tool');
  });
});


path: src/__tests__/messages.test.ts
content: import { describe, it, expect, vi, beforeEach } from 'vitest';
import { listMessages, getMessage, createMessage } from '../lib/api.js';

vi.mock('../lib/auth.js', () => ({
  getValidAccessToken: vi.fn().mockResolvedValue('test-token')
}));

vi.mock('../lib/config.js', () => ({
  getCurrentAccountId: vi.fn().mockReturnValue(123456)
}));

describe('Messages API', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('should list messages for a project message board', async () => {
    const messages = await listMessages(2);
    expect(Array.isArray(messages)).toBe(true);
    expect(messages[0]?.subject).toBe('Test Message');
  });

  it('should fetch a message by ID', async () => {
    const message = await getMessage(2, 1);
    expect(message).toBeDefined();
    expect(message.subject).toBe('Test Message');
  });

  it('should create a new message', async () => {
    const message = await createMessage(2, 'New Message', '<p>Body</p>');
    expect(message).toBeDefined();
    expect(message.subject).toBe('New Message');
  });
});


path: src/__tests__/pagination.test.ts
content: import { describe, it, expect, beforeEach, vi } from 'vitest';
import got from 'got';

describe('Pagination Helper', () => {
  describe('parseNextLink', () => {
    it('should extract next URL from RFC5988 Link header', () => {
      const linkHeader = '<https://3.basecampapi.com/999999999/projects.json?page=2>; rel="next"';
      const parseNextLink = (header: string) => {
        const match = header.match(/<([^>]+)>;\s*rel="next"/);
        return match ? match[1] : null;
      };
      
      const result = parseNextLink(linkHeader);
      expect(result).toBe('https://3.basecampapi.com/999999999/projects.json?page=2');
    });

    it('should return null for empty Link header', () => {
      const parseNextLink = (header: string | undefined) => {
        if (!header) return null;
        const match = header.match(/<([^>]+)>;\s*rel="next"/);
        return match ? match[1] : null;
      };
      
      expect(parseNextLink(undefined)).toBeNull();
      expect(parseNextLink('')).toBeNull();
    });

    it('should return null when rel="next" is not present', () => {
      const linkHeader = '<https://3.basecampapi.com/999999999/projects.json?page=1>; rel="prev"';
      const parseNextLink = (header: string) => {
        const match = header.match(/<([^>]+)>;\s*rel="next"/);
        return match ? match[1] : null;
      };
      
      expect(parseNextLink(linkHeader)).toBeNull();
    });
  });

  describe('fetchAllPages', () => {
    it('should aggregate results from multiple pages', async () => {
      const page1 = [{ id: 1, name: 'Project 1' }, { id: 2, name: 'Project 2' }];
      const page2 = [{ id: 3, name: 'Project 3' }, { id: 4, name: 'Project 4' }];
      const page3 = [{ id: 5, name: 'Project 5' }];

      const mockClient = {
        get: vi.fn()
          .mockResolvedValueOnce({
            body: page1,
            headers: {
              link: '<https://3.basecampapi.com/999999999/projects.json?page=2>; rel="next"'
            }
          })
          .mockResolvedValueOnce({
            body: page2,
            headers: {
              link: '<https://3.basecampapi.com/999999999/projects.json?page=3>; rel="next"'
            }
          })
          .mockResolvedValueOnce({
            body: page3,
            headers: { link: '' }
          })
      };

      const parseNextLink = (linkHeader: string | undefined) => {
        if (!linkHeader) return null;
        const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
        return match ? match[1] : null;
      };

      const fetchAllPages = async (client: any, url: string) => {
        const allResults: any[] = [];
        let nextUrl: string | null = url;

        while (nextUrl) {
          const response = await client.get(nextUrl);
          const items = response.body;
          allResults.push(...items);

          const linkHeader = response.headers.link;
          nextUrl = parseNextLink(linkHeader);
        }

        return allResults;
      };

      const result = await fetchAllPages(mockClient, 'projects.json');

      expect(result).toHaveLength(5);
      expect(result).toEqual([
        { id: 1, name: 'Project 1' },
        { id: 2, name: 'Project 2' },
        { id: 3, name: 'Project 3' },
        { id: 4, name: 'Project 4' },
        { id: 5, name: 'Project 5' }
      ]);
      expect(mockClient.get).toHaveBeenCalledTimes(3);
    });

    it('should handle single page response (no pagination)', async () => {
      const page1 = [{ id: 1, name: 'Project 1' }];

      const mockClient = {
        get: vi.fn().mockResolvedValueOnce({
          body: page1,
          headers: { link: '' }
        })
      };

      const parseNextLink = (linkHeader: string | undefined) => {
        if (!linkHeader) return null;
        const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
        return match ? match[1] : null;
      };

      const fetchAllPages = async (client: any, url: string) => {
        const allResults: any[] = [];
        let nextUrl: string | null = url;

        while (nextUrl) {
          const response = await client.get(nextUrl);
          const items = response.body;
          allResults.push(...items);

          const linkHeader = response.headers.link;
          nextUrl = parseNextLink(linkHeader);
        }

        return allResults;
      };

      const result = await fetchAllPages(mockClient, 'projects.json');

      expect(result).toHaveLength(1);
      expect(result).toEqual([{ id: 1, name: 'Project 1' }]);
      expect(mockClient.get).toHaveBeenCalledTimes(1);
    });

    it('should handle empty response', async () => {
      const mockClient = {
        get: vi.fn().mockResolvedValueOnce({
          body: [],
          headers: { link: '' }
        })
      };

      const parseNextLink = (linkHeader: string | undefined) => {
        if (!linkHeader) return null;
        const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
        return match ? match[1] : null;
      };

      const fetchAllPages = async (client: any, url: string) => {
        const allResults: any[] = [];
        let nextUrl: string | null = url;

        while (nextUrl) {
          const response = await client.get(nextUrl);
          const items = response.body;
          allResults.push(...items);

          const linkHeader = response.headers.link;
          nextUrl = parseNextLink(linkHeader);
        }

        return allResults;
      };

      const result = await fetchAllPages(mockClient, 'projects.json');

      expect(result).toHaveLength(0);
      expect(result).toEqual([]);
    });

    it('should preserve item order across pages', async () => {
      const page1 = [{ id: 1 }, { id: 2 }];
      const page2 = [{ id: 3 }, { id: 4 }];
      const page3 = [{ id: 5 }];

      const mockClient = {
        get: vi.fn()
          .mockResolvedValueOnce({
            body: page1,
            headers: {
              link: '<https://3.basecampapi.com/999999999/projects.json?page=2>; rel="next"'
            }
          })
          .mockResolvedValueOnce({
            body: page2,
            headers: {
              link: '<https://3.basecampapi.com/999999999/projects.json?page=3>; rel="next"'
            }
          })
          .mockResolvedValueOnce({
            body: page3,
            headers: { link: '' }
          })
      };

      const parseNextLink = (linkHeader: string | undefined) => {
        if (!linkHeader) return null;
        const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
        return match ? match[1] : null;
      };

      const fetchAllPages = async (client: any, url: string) => {
        const allResults: any[] = [];
        let nextUrl: string | null = url;

        while (nextUrl) {
          const response = await client.get(nextUrl);
          const items = response.body;
          allResults.push(...items);

          const linkHeader = response.headers.link;
          nextUrl = parseNextLink(linkHeader);
        }

        return allResults;
      };

      const result = await fetchAllPages(mockClient, 'projects.json');

      expect(result.map((r: any) => r.id)).toEqual([1, 2, 3, 4, 5]);
    });

    it('should handle query parameters in initial URL', async () => {
      const page1 = [{ id: 1, completed: true }];

      const mockClient = {
        get: vi.fn().mockResolvedValueOnce({
          body: page1,
          headers: { link: '' }
        })
      };

      const parseNextLink = (linkHeader: string | undefined) => {
        if (!linkHeader) return null;
        const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
        return match ? match[1] : null;
      };

      const fetchAllPages = async (client: any, url: string) => {
        const allResults: any[] = [];
        let nextUrl: string | null = url;

        while (nextUrl) {
          const response = await client.get(nextUrl);
          const items = response.body;
          allResults.push(...items);

          const linkHeader = response.headers.link;
          nextUrl = parseNextLink(linkHeader);
        }

        return allResults;
      };

      const result = await fetchAllPages(mockClient, 'todos.json?completed=true');

      expect(mockClient.get).toHaveBeenCalledWith('todos.json?completed=true');
      expect(result).toEqual([{ id: 1, completed: true }]);
    });
  });
});


path: src/__tests__/projects.test.ts
content: import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from './setup';
import {
  listProjects,
  getProject,
  createProject,
} from '../lib/api.js';

const mockProject = {
  id: 1,
  status: 'active',
  created_at: '2024-01-01T00:00:00.000Z',
  updated_at: '2024-01-15T10:00:00.000Z',
  name: 'Test Project',
  description: 'A test project description',
  purpose: 'topic',
  clients_enabled: false,
  bookmark_url: 'https://3.basecampapi.com/99999999/my/bookmarks/xxx.json',
  url: 'https://3.basecampapi.com/99999999/projects/1.json',
  app_url: 'https://3.basecamp.com/99999999/projects/1',
  dock: [
    {
      id: 10,
      title: 'Campfire',
      name: 'chat',
      enabled: true,
      position: 1,
    },
    {
      id: 11,
      title: 'Message Board',
      name: 'message_board',
      enabled: true,
      position: 2,
    },
    {
      id: 12,
      title: 'To-dos',
      name: 'todoset',
      enabled: true,
      position: 3,
    },
    {
      id: 13,
      title: 'Schedule',
      name: 'schedule',
      enabled: true,
      position: 4,
    },
  ],
  bookmarked: false,
};

const mockProjects = [
  mockProject,
  {
    ...mockProject,
    id: 2,
    name: 'WEDU - Marc Cassaigneau',
    description: 'Projet principal',
  },
  {
    ...mockProject,
    id: 3,
    name: 'Client - Conforma',
    description: 'Mission RGPD',
    status: 'active',
  },
  {
    ...mockProject,
    id: 4,
    name: 'Archived Project',
    description: 'Old project',
    status: 'archived',
  },
];

describe('Projects API', () => {
  beforeEach(() => {
    server.resetHandlers();
  });

  describe('listProjects', () => {
    it('should list all active projects', async () => {
      server.use(
        http.get(
          `https://3.basecampapi.com/:accountId/projects.json`,
          () => {
            return HttpResponse.json(mockProjects.filter(p => p.status === 'active'));
          }
        )
      );

      const projects = await listProjects();

      expect(projects.length).toBeGreaterThan(0);
      expect(projects.every(p => p.status === 'active')).toBe(true);
    });

    it('should return project with dock information', async () => {
      server.use(
        http.get(
          `https://3.basecampapi.com/:accountId/projects.json`,
          () => {
            return HttpResponse.json([mockProject]);
          }
        )
      );

      const projects = await listProjects();

      expect(projects[0].dock).toBeDefined();
      expect(projects[0].dock.length).toBe(4);
      expect(projects[0].dock[0].name).toBe('chat');
    });

    it('should handle empty project list', async () => {
      server.use(
        http.get(
          `https://3.basecampapi.com/:accountId/projects.json`,
          () => {
            return HttpResponse.json([]);
          }
        )
      );

      const projects = await listProjects();

      expect(projects).toHaveLength(0);
    });
  });

  describe('getProject', () => {
    it('should get a single project with full details', async () => {
      server.use(
        http.get(
          `https://3.basecampapi.com/:accountId/projects/:projectId.json`,
          () => {
            return HttpResponse.json(mockProject);
          }
        )
      );

      const project = await getProject(mockProject.id);

      expect(project.id).toBe(mockProject.id);
      expect(project.name).toBe('Test Project');
      expect(project.description).toBe('A test project description');
      expect(project.dock).toHaveLength(4);
    });

    it('should handle project not found', async () => {
      server.use(
        http.get(
          `https://3.basecampapi.com/:accountId/projects/:projectId.json`,
          () => {
            return new HttpResponse(null, { status: 404 });
          }
        )
      );

      await expect(getProject(9999)).rejects.toThrow();
    });
  });

  describe('createProject', () => {
    it('should create a new project', async () => {
      server.use(
        http.post(
          `https://3.basecampapi.com/:accountId/projects.json`,
          async ({ request }) => {
            const body = (await request.json()) as Record<string, unknown>;
            return HttpResponse.json(
              {
                ...mockProject,
                id: 100,
                name: body.name,
                description: body.description,
              },
              { status: 201 }
            );
          }
        )
      );

      const project = await createProject('New Project', 'Project description');

      expect(project.id).toBe(100);
      expect(project.name).toBe('New Project');
      expect(project.description).toBe('Project description');
    });

    it('should create project with minimal data', async () => {
      server.use(
        http.post(
          `https://3.basecampapi.com/:accountId/projects.json`,
          async ({ request }) => {
            const body = (await request.json()) as Record<string, unknown>;
            return HttpResponse.json(
              {
                ...mockProject,
                id: 101,
                name: body.name,
                description: '',
              },
              { status: 201 }
            );
          }
        )
      );

      const project = await createProject('Minimal Project');

      expect(project.id).toBe(101);
      expect(project.name).toBe('Minimal Project');
    });
  });
});

describe('Projects Edge Cases', () => {
  it('should handle special characters in project name', async () => {
    const specialName = 'Client - Société "ABC" & Co';

    server.use(
      http.post(
        `https://3.basecampapi.com/:accountId/projects.json`,
        async ({ request }) => {
          const body = (await request.json()) as Record<string, unknown>;
          return HttpResponse.json(
            {
              ...mockProject,
              id: 200,
              name: body.name,
            },
            { status: 201 }
          );
        }
      )
    );

    const project = await createProject(specialName);

    expect(project.name).toBe(specialName);
  });

  it('should handle unicode in project names', async () => {
    const unicodeName = '🧪 TEST - Basecamp CLI';

    server.use(
      http.post(
        `https://3.basecampapi.com/:accountId/projects.json`,
        async ({ request }) => {
          const body = (await request.json()) as Record<string, unknown>;
          return HttpResponse.json(
            {
              ...mockProject,
              id: 201,
              name: body.name,
            },
            { status: 201 }
          );
        }
      )
    );

    const project = await createProject(unicodeName);

    expect(project.name).toBe(unicodeName);
  });
});


path: src/__tests__/recordings.test.ts
content: import { describe, it, expect, vi, beforeEach } from 'vitest';
import { listRecordings, archiveRecording, restoreRecording, trashRecording } from '../lib/api.js';

vi.mock('../lib/auth.js', () => ({
  getValidAccessToken: vi.fn().mockResolvedValue('test-token')
}));

vi.mock('../lib/config.js', () => ({
  getCurrentAccountId: vi.fn().mockReturnValue(123456)
}));

describe('Recordings API', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('listRecordings', () => {
    it('should list recordings by type', async () => {
      const recordings = await listRecordings('Todo');
      expect(recordings).toBeDefined();
      expect(Array.isArray(recordings)).toBe(true);
    });

    it('should support bucket filter', async () => {
      const recordings = await listRecordings('Message', { bucket: [1, 2, 3] });
      expect(recordings).toBeDefined();
      expect(Array.isArray(recordings)).toBe(true);
    });

    it('should support status filter', async () => {
      const recordings = await listRecordings('Document', { status: 'archived' });
      expect(recordings).toBeDefined();
      expect(Array.isArray(recordings)).toBe(true);
    });

    it('should support sort options', async () => {
      const recordings = await listRecordings('Upload', {
        sort: 'updated_at',
        direction: 'asc'
      });
      expect(recordings).toBeDefined();
      expect(Array.isArray(recordings)).toBe(true);
    });

    it('should handle all options together', async () => {
      const recordings = await listRecordings('Comment', {
        bucket: [1, 2],
        status: 'active',
        sort: 'created_at',
        direction: 'desc'
      });
      expect(recordings).toBeDefined();
      expect(Array.isArray(recordings)).toBe(true);
    });
  });

  describe('archiveRecording', () => {
    it('should archive a recording', async () => {
      await expect(archiveRecording(1, 100)).resolves.toBeUndefined();
    });

    it('should handle archive errors', async () => {
      await expect(archiveRecording(999, 999)).rejects.toThrow();
    });
  });

  describe('restoreRecording', () => {
    it('should restore a recording', async () => {
      await expect(restoreRecording(1, 100)).resolves.toBeUndefined();
    });

    it('should handle restore errors', async () => {
      await expect(restoreRecording(999, 999)).rejects.toThrow();
    });
  });

  describe('trashRecording', () => {
    it('should trash a recording', async () => {
      await expect(trashRecording(1, 100)).resolves.toBeUndefined();
    });

    it('should handle trash errors', async () => {
      await expect(trashRecording(999, 999)).rejects.toThrow();
    });
  });
});


path: src/__tests__/retry.test.ts
content: import { describe, it, expect, beforeEach, vi } from 'vitest';
import got, { Got, HTTPError } from 'got';

describe('Retry Logic with Exponential Backoff', () => {
  let client: Got;

  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('Retry Configuration', () => {
    it('should retry on 429 (Too Many Requests)', async () => {
      const mockClient = got.extend({
        retry: {
          limit: 3,
          methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
          statusCodes: [429, 500, 502, 503, 504],
          errorCodes: [],
          calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
            return Math.pow(2, attemptCount - 1) * 1000;
          }
        }
      });

      expect(mockClient.defaults.options.retry).toBeDefined();
      expect(mockClient.defaults.options.retry.limit).toBe(3);
      expect(mockClient.defaults.options.retry.statusCodes).toContain(429);
    });

    it('should retry on 5xx errors (500, 502, 503, 504)', async () => {
      const mockClient = got.extend({
        retry: {
          limit: 3,
          methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
          statusCodes: [429, 500, 502, 503, 504],
          errorCodes: [],
          calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
            return Math.pow(2, attemptCount - 1) * 1000;
          }
        }
      });

      const retryConfig = mockClient.defaults.options.retry;
      expect(retryConfig.statusCodes).toContain(500);
      expect(retryConfig.statusCodes).toContain(502);
      expect(retryConfig.statusCodes).toContain(503);
      expect(retryConfig.statusCodes).toContain(504);
    });

    it('should NOT retry on 4xx errors (except 429)', async () => {
      const mockClient = got.extend({
        retry: {
          limit: 3,
          methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
          statusCodes: [429, 500, 502, 503, 504],
          errorCodes: [],
          calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
            return Math.pow(2, attemptCount - 1) * 1000;
          }
        }
      });

      const retryConfig = mockClient.defaults.options.retry;
      expect(retryConfig.statusCodes).not.toContain(400);
      expect(retryConfig.statusCodes).not.toContain(401);
      expect(retryConfig.statusCodes).not.toContain(403);
      expect(retryConfig.statusCodes).not.toContain(404);
    });

    it('should limit retries to 3 attempts', async () => {
      const mockClient = got.extend({
        retry: {
          limit: 3,
          methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
          statusCodes: [429, 500, 502, 503, 504],
          errorCodes: [],
          calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
            return Math.pow(2, attemptCount - 1) * 1000;
          }
        }
      });

      expect(mockClient.defaults.options.retry.limit).toBe(3);
    });
  });

  describe('Exponential Backoff Calculation', () => {
    it('should calculate 1s delay for first retry (2^0 * 1000)', () => {
      const calculateDelay = ({ attemptCount }: { attemptCount: number }) => {
        return Math.pow(2, attemptCount - 1) * 1000;
      };

      expect(calculateDelay({ attemptCount: 1 })).toBe(1000);
    });

    it('should calculate 2s delay for second retry (2^1 * 1000)', () => {
      const calculateDelay = ({ attemptCount }: { attemptCount: number }) => {
        return Math.pow(2, attemptCount - 1) * 1000;
      };

      expect(calculateDelay({ attemptCount: 2 })).toBe(2000);
    });

    it('should calculate 4s delay for third retry (2^2 * 1000)', () => {
      const calculateDelay = ({ attemptCount }: { attemptCount: number }) => {
        return Math.pow(2, attemptCount - 1) * 1000;
      };

      expect(calculateDelay({ attemptCount: 3 })).toBe(4000);
    });
  });

  describe('Retry-After Header Handling', () => {
    it('should parse Retry-After header with numeric seconds', () => {
      const getRetryAfterDelay = (response: any): number | undefined => {
        const retryAfter = response.headers['retry-after'];
        if (!retryAfter) return undefined;

        const seconds = parseInt(retryAfter, 10);
        if (!isNaN(seconds)) {
          return seconds * 1000;
        }

        const retryDate = new Date(retryAfter);
        if (!isNaN(retryDate.getTime())) {
          return Math.max(0, retryDate.getTime() - Date.now());
        }

        return undefined;
      };

      const response = {
        headers: { 'retry-after': '60' }
      };

      const delay = getRetryAfterDelay(response);
      expect(delay).toBe(60000);
    });

    it('should parse Retry-After header with HTTP-date format', () => {
      const getRetryAfterDelay = (response: any): number | undefined => {
        const retryAfter = response.headers['retry-after'];
        if (!retryAfter) return undefined;

        const seconds = parseInt(retryAfter, 10);
        if (!isNaN(seconds)) {
          return seconds * 1000;
        }

        const retryDate = new Date(retryAfter);
        if (!isNaN(retryDate.getTime())) {
          return Math.max(0, retryDate.getTime() - Date.now());
        }

        return undefined;
      };

      const futureDate = new Date(Date.now() + 30000).toUTCString();
      const response = {
        headers: { 'retry-after': futureDate }
      };

      const delay = getRetryAfterDelay(response);
      expect(delay).toBeGreaterThan(0);
      expect(delay).toBeLessThanOrEqual(30000);
    });

    it('should return undefined when Retry-After header is missing', () => {
      const getRetryAfterDelay = (response: any): number | undefined => {
        const retryAfter = response.headers['retry-after'];
        if (!retryAfter) return undefined;

        const seconds = parseInt(retryAfter, 10);
        if (!isNaN(seconds)) {
          return seconds * 1000;
        }

        const retryDate = new Date(retryAfter);
        if (!isNaN(retryDate.getTime())) {
          return Math.max(0, retryDate.getTime() - Date.now());
        }

        return undefined;
      };

      const response = {
        headers: {}
      };

      const delay = getRetryAfterDelay(response);
      expect(delay).toBeUndefined();
    });

    it('should handle invalid Retry-After values gracefully', () => {
      const getRetryAfterDelay = (response: any): number | undefined => {
        const retryAfter = response.headers['retry-after'];
        if (!retryAfter) return undefined;

        const seconds = parseInt(retryAfter, 10);
        if (!isNaN(seconds)) {
          return seconds * 1000;
        }

        const retryDate = new Date(retryAfter);
        if (!isNaN(retryDate.getTime())) {
          return Math.max(0, retryDate.getTime() - Date.now());
        }

        return undefined;
      };

      const response = {
        headers: { 'retry-after': 'invalid-value' }
      };

      const delay = getRetryAfterDelay(response);
      expect(delay).toBeUndefined();
    });
  });

  describe('HTTP Methods Retry Support', () => {
    it('should retry GET requests', () => {
      const retryConfig = {
        limit: 3,
        methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
        statusCodes: [429, 500, 502, 503, 504],
        errorCodes: [],
        calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
          return Math.pow(2, attemptCount - 1) * 1000;
        }
      };

      expect(retryConfig.methods).toContain('GET');
    });

    it('should retry POST requests', () => {
      const retryConfig = {
        limit: 3,
        methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
        statusCodes: [429, 500, 502, 503, 504],
        errorCodes: [],
        calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
          return Math.pow(2, attemptCount - 1) * 1000;
        }
      };

      expect(retryConfig.methods).toContain('POST');
    });

    it('should retry PUT requests', () => {
      const retryConfig = {
        limit: 3,
        methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
        statusCodes: [429, 500, 502, 503, 504],
        errorCodes: [],
        calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
          return Math.pow(2, attemptCount - 1) * 1000;
        }
      };

      expect(retryConfig.methods).toContain('PUT');
    });

    it('should retry DELETE requests', () => {
      const retryConfig = {
        limit: 3,
        methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
        statusCodes: [429, 500, 502, 503, 504],
        errorCodes: [],
        calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
          return Math.pow(2, attemptCount - 1) * 1000;
        }
      };

      expect(retryConfig.methods).toContain('DELETE');
    });
  });

  describe('Error Handling After Max Retries', () => {
    it('should preserve original error after max retries exceeded', () => {
      const retryConfig = {
        limit: 3,
        methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
        statusCodes: [429, 500, 502, 503, 504],
        errorCodes: [],
        calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
          return Math.pow(2, attemptCount - 1) * 1000;
        }
      };

      expect(retryConfig.limit).toBe(3);
      expect(retryConfig.statusCodes).toContain(429);
    });

    it('should throw error with 429 status after max retries', () => {
      const retryConfig = {
        limit: 3,
        methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
        statusCodes: [429, 500, 502, 503, 504],
        errorCodes: [],
        calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
          return Math.pow(2, attemptCount - 1) * 1000;
        }
      };

      expect(retryConfig.statusCodes).toContain(429);
      expect(retryConfig.limit).toBe(3);
    });

    it('should throw error with 5xx status after max retries', () => {
      const retryConfig = {
        limit: 3,
        methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
        statusCodes: [429, 500, 502, 503, 504],
        errorCodes: [],
        calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
          return Math.pow(2, attemptCount - 1) * 1000;
        }
      };

      expect(retryConfig.statusCodes).toContain(503);
      expect(retryConfig.limit).toBe(3);
    });
  });

  describe('Retry Scenarios', () => {
    it('should handle 429 with Retry-After header', () => {
      const response = {
        statusCode: 429,
        headers: { 'retry-after': '30' }
      };

      const getRetryAfterDelay = (resp: any): number | undefined => {
        const retryAfter = resp.headers['retry-after'];
        if (!retryAfter) return undefined;

        const seconds = parseInt(retryAfter, 10);
        if (!isNaN(seconds)) {
          return seconds * 1000;
        }

        const retryDate = new Date(retryAfter);
        if (!isNaN(retryDate.getTime())) {
          return Math.max(0, retryDate.getTime() - Date.now());
        }

        return undefined;
      };

      const delay = getRetryAfterDelay(response);
      expect(delay).toBe(30000);
      expect(response.statusCode).toBe(429);
    });

    it('should handle 429 without Retry-After header', () => {
      const response = {
        statusCode: 429,
        headers: {}
      };

      const getRetryAfterDelay = (resp: any): number | undefined => {
        const retryAfter = resp.headers['retry-after'];
        if (!retryAfter) return undefined;

        const seconds = parseInt(retryAfter, 10);
        if (!isNaN(seconds)) {
          return seconds * 1000;
        }

        const retryDate = new Date(retryAfter);
        if (!isNaN(retryDate.getTime())) {
          return Math.max(0, retryDate.getTime() - Date.now());
        }

        return undefined;
      };

      const delay = getRetryAfterDelay(response);
      expect(delay).toBeUndefined();
      expect(response.statusCode).toBe(429);
    });

    it('should handle 500 server error', () => {
      const response = {
        statusCode: 500,
        headers: {}
      };

      expect(response.statusCode).toBe(500);
    });

    it('should handle 502 bad gateway error', () => {
      const response = {
        statusCode: 502,
        headers: {}
      };

      expect(response.statusCode).toBe(502);
    });

    it('should handle 503 service unavailable error', () => {
      const response = {
        statusCode: 503,
        headers: {}
      };

      expect(response.statusCode).toBe(503);
    });

    it('should handle 504 gateway timeout error', () => {
      const response = {
        statusCode: 504,
        headers: {}
      };

      expect(response.statusCode).toBe(504);
    });
  });

  describe('Non-Retryable Errors', () => {
    it('should NOT retry on 400 Bad Request', () => {
      const retryConfig = {
        limit: 3,
        methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
        statusCodes: [429, 500, 502, 503, 504],
        errorCodes: [],
        calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
          return Math.pow(2, attemptCount - 1) * 1000;
        }
      };

      expect(retryConfig.statusCodes).not.toContain(400);
    });

    it('should NOT retry on 401 Unauthorized', () => {
      const retryConfig = {
        limit: 3,
        methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
        statusCodes: [429, 500, 502, 503, 504],
        errorCodes: [],
        calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
          return Math.pow(2, attemptCount - 1) * 1000;
        }
      };

      expect(retryConfig.statusCodes).not.toContain(401);
    });

    it('should NOT retry on 403 Forbidden', () => {
      const retryConfig = {
        limit: 3,
        methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
        statusCodes: [429, 500, 502, 503, 504],
        errorCodes: [],
        calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
          return Math.pow(2, attemptCount - 1) * 1000;
        }
      };

      expect(retryConfig.statusCodes).not.toContain(403);
    });

    it('should NOT retry on 404 Not Found', () => {
      const retryConfig = {
        limit: 3,
        methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'],
        statusCodes: [429, 500, 502, 503, 504],
        errorCodes: [],
        calculateDelay: ({ attemptCount }: { attemptCount: number }) => {
          return Math.pow(2, attemptCount - 1) * 1000;
        }
      };

      expect(retryConfig.statusCodes).not.toContain(404);
    });
  });
});


path: src/__tests__/search.test.ts
content: import { describe, it, expect } from 'vitest';

describe('search', () => {
  it('should export search function from api module', async () => {
    const { search } = await import('../lib/api.js');
    expect(typeof search).toBe('function');
  });

  it('search function should accept query parameter', async () => {
    const { search } = await import('../lib/api.js');
    const fn = search as any;
    expect(fn.length).toBeGreaterThanOrEqual(1);
  });

  it('search function should accept optional options parameter', async () => {
    const { search } = await import('../lib/api.js');
    const fn = search as any;
    expect(fn.length).toBeGreaterThanOrEqual(1);
  });

  it('should handle query with type filter option', async () => {
    const { search } = await import('../lib/api.js');
    expect(typeof search).toBe('function');
  });

  it('should handle query with bucket_id filter option', async () => {
    const { search } = await import('../lib/api.js');
    expect(typeof search).toBe('function');
  });

  it('should handle query with creator_id filter option', async () => {
    const { search } = await import('../lib/api.js');
    expect(typeof search).toBe('function');
  });

  it('should handle query with file_type filter option', async () => {
    const { search } = await import('../lib/api.js');
    expect(typeof search).toBe('function');
  });

  it('should handle query with exclude_chat option', async () => {
    const { search } = await import('../lib/api.js');
    expect(typeof search).toBe('function');
  });

  it('should handle query with pagination options', async () => {
    const { search } = await import('../lib/api.js');
    expect(typeof search).toBe('function');
  });

  it('should handle multiple filter options together', async () => {
    const { search } = await import('../lib/api.js');
    expect(typeof search).toBe('function');
  });

  it('should be async function', async () => {
    const { search } = await import('../lib/api.js');
    const result = await search('test');
    expect(Array.isArray(result)).toBe(true);
    expect(result[0]?.id).toBe(7001);
  });
});


path: src/lib/version.ts
content: // Version injected at build time via tsup.config.ts
export const VERSION: string = process.env.PKG_VERSION || '2.0.0';
export const NAME: string = process.env.PKG_NAME || '@drkraft/basecamp-cli';


path: src/mcp.ts
content: #!/usr/bin/env node
import { startServer } from './mcp/server.js';

startServer().catch((error) => {
  console.error('Failed to start MCP server:', error);
  process.exit(1);
});


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)