Skip to content

Commit 0d3a5ea

Browse files
committed
feat: improve onboarding - helpful init intro, token URL in errors, non-TTY detection, clearer README for agents
1 parent 74a28de commit 0d3a5ea

File tree

6 files changed

+140
-39
lines changed

6 files changed

+140
-39
lines changed

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,24 @@ npm install -g @krodak/clickup-cli && cup init
1616

1717
## For AI Agents
1818

19-
Paste this into any AI agent to get started immediately:
19+
Paste this into any AI agent (Claude Code, Codex, Cursor, OpenCode, etc.):
2020

2121
```
22-
Fetch and follow instructions from https://raw.githubusercontent.com/krodak/clickup-cli/main/skills/clickup-cli/SKILL.md
22+
Install and configure the ClickUp CLI for me. Fetch the setup guide from:
23+
https://raw.githubusercontent.com/krodak/clickup-cli/main/skills/clickup-cli/SKILL.md
24+
25+
Then walk me through installing the CLI, getting a ClickUp API token,
26+
and running cup init. Finally, install the skill with `cup skill` so
27+
you have persistent access to the full command reference.
2328
```
2429

25-
Or install the skill permanently with `cup skill` (see [Set up your agent](#set-up-your-agent) below).
30+
The fetched SKILL.md contains everything the agent needs: install commands,
31+
where to get a ClickUp API token, non-interactive setup with `cup init --token --team`,
32+
and the complete command reference. After setup, the agent can run any `cup` command
33+
to manage your tasks, sprints, comments, time tracking, and more.
34+
35+
**Already have the CLI installed?** Just run `cup skill` to install the agent skill to
36+
all detected locations (see [Set up your agent](#set-up-your-agent) below).
2637

2738
## Talk to your agent
2839

skills/clickup-cli/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ name: clickup
33
description: 'Use when managing ClickUp tasks, sprints, or comments via the `cup` CLI tool. Triggers: task queries, status updates, sprint tracking, creating subtasks, posting comments, threaded replies, standup summaries, searching tasks, checking overdue items, assigning tasks, listing spaces and lists, opening tasks in browser, checking auth or config, setting custom fields, deleting tasks, managing tags, managing checklists, editing comments, task links, time tracking, attachments, file uploads, listing members, listing fields, duplicating tasks, bulk operations, goals, key results, saved filters, favorites.'
44
---
55

6-
# ClickUp CLI (`cup`) - skill version 1.17.0
6+
# ClickUp CLI (`cup`) - skill version 1.22.0
77

8-
Reference for AI agents using the `cup` CLI tool. Covers task management, sprint tracking, comments, and project workflows.
8+
Reference for AI agents using the `cup` CLI tool. Covers task management, sprint tracking, comments, time tracking, custom fields, goals, docs, and project workflows.
99

10-
> **Version check:** Run `cup --version`. If your installed version is older than 1.18.0, update with `npm install -g @krodak/clickup-cli` and refresh this skill with `cup skill`.
10+
> **Version check:** Run `cup --version`. If your installed version is older than 1.22.0, update with `npm install -g @krodak/clickup-cli` and refresh this skill with `cup skill`.
1111
1212
## Install & Configure
1313

src/commands/init.ts

Lines changed: 68 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,80 @@ export interface InitOptions {
88
team?: string
99
}
1010

11+
const TOKEN_URL = 'https://app.clickup.com/settings/apps'
12+
13+
const TOKEN_HELP = `
14+
How to get a ClickUp API token:
15+
1. Open ${TOKEN_URL}
16+
2. Find the "API Token" section, click "Generate" (or copy the existing one)
17+
3. The token starts with "pk_"
18+
`
19+
20+
const NON_TTY_HELP = `cup init requires an interactive terminal.
21+
22+
For scripts, CI, or AI agents, use flags:
23+
cup init --token pk_YOUR_TOKEN --team YOUR_TEAM_ID
24+
25+
Or set environment variables:
26+
export CU_API_TOKEN=pk_YOUR_TOKEN
27+
export CU_TEAM_ID=YOUR_TEAM_ID
28+
${TOKEN_HELP}`
29+
30+
const NEXT_STEPS = `
31+
Next steps:
32+
cup auth # verify setup
33+
cup tasks # list your assigned tasks
34+
cup sprint # show current sprint
35+
cup --help # see all commands
36+
`
37+
38+
function validateTokenFormat(token: string): void {
39+
if (!token.startsWith('pk_')) {
40+
throw new Error(
41+
`Invalid token format. Personal API tokens start with "pk_".\n${TOKEN_HELP}`,
42+
)
43+
}
44+
}
45+
46+
async function verifyToken(apiToken: string): Promise<string> {
47+
const client = new ClickUpClient({ apiToken })
48+
try {
49+
const me = await client.getMe()
50+
return me.username
51+
} catch (err) {
52+
const msg = err instanceof Error ? err.message : String(err)
53+
throw new Error(
54+
`Token verification failed: ${msg}\n\nCheck the token is correct and not expired.\n${TOKEN_HELP}`,
55+
{ cause: err },
56+
)
57+
}
58+
}
59+
1160
export async function runInitCommand(opts?: InitOptions): Promise<void> {
1261
if (opts?.token && opts?.team) {
1362
const apiToken = opts.token.trim()
14-
if (!apiToken.startsWith('pk_')) throw new Error('Token must start with pk_')
15-
16-
const client = new ClickUpClient({ apiToken })
17-
let username: string
18-
try {
19-
const me = await client.getMe()
20-
username = me.username
21-
} catch (err) {
22-
throw new Error(`Invalid token: ${err instanceof Error ? err.message : String(err)}`, {
23-
cause: err,
24-
})
25-
}
26-
63+
validateTokenFormat(apiToken)
64+
const username = await verifyToken(apiToken)
2765
process.stdout.write(`Authenticated as @${username}\n`)
2866
writeConfig({ apiToken, teamId: opts.team })
29-
process.stdout.write(`Config written to ${getConfigPath()}\n`)
67+
process.stdout.write(`Config written to ${getConfigPath()}\n${NEXT_STEPS}`)
3068
return
3169
}
3270

3371
if (opts?.token || opts?.team) {
3472
throw new Error('Both --token and --team are required for non-interactive setup')
3573
}
3674

75+
if (!process.stdin.isTTY) {
76+
throw new Error(NON_TTY_HELP)
77+
}
78+
3779
const configPath = getConfigPath()
3880

81+
process.stdout.write('\nWelcome to ClickUp CLI!\n')
82+
process.stdout.write(TOKEN_HELP)
83+
process.stdout.write('\n')
84+
3985
if (fs.existsSync(configPath)) {
4086
const overwrite = await confirm({
4187
message: `Config already exists at ${configPath}. Overwrite?`,
@@ -47,23 +93,17 @@ export async function runInitCommand(opts?: InitOptions): Promise<void> {
4793
}
4894
}
4995

50-
const apiToken = (await password({ message: 'ClickUp API token (pk_...):' })).trim()
51-
if (!apiToken.startsWith('pk_')) throw new Error('Token must start with pk_')
52-
53-
const client = new ClickUpClient({ apiToken })
54-
55-
let username: string
56-
try {
57-
const me = await client.getMe()
58-
username = me.username
59-
} catch (err) {
60-
throw new Error(`Invalid token: ${err instanceof Error ? err.message : String(err)}`, {
61-
cause: err,
96+
const apiToken = (
97+
await password({
98+
message: 'Paste your ClickUp API token (starts with pk_):',
6299
})
63-
}
100+
).trim()
101+
validateTokenFormat(apiToken)
64102

103+
const username = await verifyToken(apiToken)
65104
process.stdout.write(`Authenticated as @${username}\n`)
66105

106+
const client = new ClickUpClient({ apiToken })
67107
const teams = await client.getTeams()
68108
if (teams.length === 0) throw new Error('No workspaces found for this token.')
69109

@@ -81,5 +121,5 @@ export async function runInitCommand(opts?: InitOptions): Promise<void> {
81121
}
82122

83123
writeConfig({ apiToken, teamId })
84-
process.stdout.write(`Config written to ${configPath}\n`)
124+
process.stdout.write(`\nConfig written to ${configPath}\n${NEXT_STEPS}`)
85125
}

src/config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,14 @@ export function loadConfig(profileName?: string): Config {
217217
if (envToken || envTeamId) {
218218
throw new Error('Both CU_API_TOKEN and CU_TEAM_ID must be set, or run: cup init')
219219
}
220-
throw new Error('Config missing required field: apiToken.\nSet CU_API_TOKEN or run: cup init')
220+
throw new Error(
221+
'No ClickUp CLI configuration found.\n\n' +
222+
'To get started:\n' +
223+
' cup init\n\n' +
224+
'For scripts or AI agents:\n' +
225+
' cup init --token pk_YOUR_TOKEN --team YOUR_TEAM_ID\n\n' +
226+
'Get your API token: https://app.clickup.com/settings/apps',
227+
)
221228
}
222229

223230
const { parsed } = parseRawConfig(path)

tests/unit/commands/init.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest'
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
22

33
const mockPassword = vi.fn()
44
const mockConfirm = vi.fn()
@@ -41,12 +41,22 @@ vi.mock('fs', async importOriginal => {
4141
})
4242

4343
describe('runInitCommand', () => {
44+
const originalIsTTY = process.stdin.isTTY
45+
4446
beforeEach(() => {
4547
vi.clearAllMocks()
4648
mockGetMe.mockResolvedValue({ id: 1, username: 'testuser' })
4749
mockGetTeams.mockResolvedValue([{ id: 'team1', name: 'My Workspace' }])
4850
mockPassword.mockResolvedValue('pk_testtoken')
4951
mockExistsSync.mockReturnValue(false)
52+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true })
53+
})
54+
55+
afterEach(() => {
56+
Object.defineProperty(process.stdin, 'isTTY', {
57+
value: originalIsTTY,
58+
configurable: true,
59+
})
5060
})
5161

5262
it('writes config with apiToken and teamId when single workspace', async () => {
@@ -99,6 +109,39 @@ describe('runInitCommand', () => {
99109
await expect(runInitCommand()).rejects.toThrow('No workspaces')
100110
})
101111

112+
it('shows API token help intro in interactive mode', async () => {
113+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
114+
const { runInitCommand } = await import('../../../src/commands/init.js')
115+
await runInitCommand()
116+
const output = writeSpy.mock.calls.map(c => c[0]).join('')
117+
expect(output).toContain('Welcome to ClickUp CLI')
118+
expect(output).toContain('https://app.clickup.com/settings/apps')
119+
expect(output).toContain('API Token')
120+
writeSpy.mockRestore()
121+
})
122+
123+
it('shows next-steps hint after successful config write', async () => {
124+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
125+
const { runInitCommand } = await import('../../../src/commands/init.js')
126+
await runInitCommand()
127+
const output = writeSpy.mock.calls.map(c => c[0]).join('')
128+
expect(output).toContain('Next steps')
129+
expect(output).toContain('cup auth')
130+
writeSpy.mockRestore()
131+
})
132+
133+
it('throws with non-TTY help when stdin is not a terminal', async () => {
134+
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true })
135+
const { runInitCommand } = await import('../../../src/commands/init.js')
136+
await expect(runInitCommand()).rejects.toThrow('cup init --token')
137+
})
138+
139+
it('invalid token error includes token URL', async () => {
140+
mockPassword.mockResolvedValue('nottoken')
141+
const { runInitCommand } = await import('../../../src/commands/init.js')
142+
await expect(runInitCommand()).rejects.toThrow('app.clickup.com/settings/apps')
143+
})
144+
102145
describe('non-interactive mode', () => {
103146
it('writes config with --token and --team without prompts', async () => {
104147
const { runInitCommand } = await import('../../../src/commands/init.js')
@@ -137,7 +180,7 @@ describe('runInitCommand', () => {
137180
mockGetMe.mockRejectedValue(new Error('Unauthorized'))
138181
const { runInitCommand } = await import('../../../src/commands/init.js')
139182
await expect(runInitCommand({ token: 'pk_badtoken', team: 'team123' })).rejects.toThrow(
140-
'Invalid token',
183+
'Token verification failed',
141184
)
142185
})
143186
})

tests/unit/config.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ describe('loadConfig', () => {
4747
restoreConfigEnv()
4848
})
4949

50-
it('throws with path hint when config file does not exist and no env vars', async () => {
50+
it('throws with onboarding help when config file does not exist and no env vars', async () => {
5151
vi.mocked(fs.existsSync).mockReturnValue(false)
5252
const { loadConfig } = await import('../../src/config.js')
53-
expect(() => loadConfig()).toThrow('apiToken')
53+
expect(() => loadConfig()).toThrow('cup init')
5454
})
5555

5656
it('throws on invalid JSON', async () => {

0 commit comments

Comments
 (0)