Skip to content

Commit 9fd71e7

Browse files
lmjabreuclaudescottlovegrove
authored
feat: add away status command (#79)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Scott Lovegrove <scott@ferretlabs.com>
1 parent 2168090 commit 9fd71e7

File tree

10 files changed

+379
-6
lines changed

10 files changed

+379
-6
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ tw conversation view <ref> # view conversation messages
7474
tw msg view <ref> # view a conversation message
7575
tw search "keyword" # search across workspace
7676
tw react thread <ref> 👍 # add reaction
77+
tw away # show away status
78+
tw away set vacation 2026-03-20 # set away until date
79+
tw away clear # clear away status
7780
```
7881

7982
References accept IDs (`123` or `id:123`), Twist URLs, or fuzzy names (for workspaces/users).

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"dist"
4848
],
4949
"dependencies": {
50-
"@doist/twist-sdk": "2.0.2",
50+
"@doist/twist-sdk": "2.1.0",
5151
"@pnpm/tabtab": "0.5.4",
5252
"chalk": "5.6.2",
5353
"commander": "14.0.3",

src/__tests__/away.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { TwistRequestError } from '@doist/twist-sdk'
2+
import { Command } from 'commander'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
const apiMocks = vi.hoisted(() => ({
6+
getSessionUser: vi.fn(),
7+
getTwistClient: vi.fn(),
8+
updateUser: vi.fn(),
9+
}))
10+
11+
vi.mock('../lib/api.js', () => ({
12+
getSessionUser: apiMocks.getSessionUser,
13+
getTwistClient: apiMocks.getTwistClient,
14+
}))
15+
16+
vi.mock('chalk')
17+
18+
import { registerAwayCommand } from '../commands/away.js'
19+
20+
function createProgram() {
21+
const program = new Command()
22+
program.exitOverride()
23+
registerAwayCommand(program)
24+
return program
25+
}
26+
27+
describe('away', () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
apiMocks.updateUser.mockResolvedValue({
31+
id: 1,
32+
name: 'Test User',
33+
email: 'test@example.com',
34+
awayMode: null,
35+
})
36+
apiMocks.getTwistClient.mockResolvedValue({
37+
users: { update: apiMocks.updateUser },
38+
})
39+
})
40+
41+
describe('show', () => {
42+
it('shows not away when awayMode is null', async () => {
43+
apiMocks.getSessionUser.mockResolvedValue({
44+
id: 1,
45+
name: 'Test User',
46+
awayMode: null,
47+
})
48+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
49+
const program = createProgram()
50+
51+
await program.parseAsync(['node', 'tw', 'away'])
52+
53+
expect(logSpy).toHaveBeenCalledWith('Not away.')
54+
logSpy.mockRestore()
55+
})
56+
57+
it('shows away status when set', async () => {
58+
apiMocks.getSessionUser.mockResolvedValue({
59+
id: 1,
60+
name: 'Test User',
61+
awayMode: { type: 'vacation', dateFrom: '2026-03-10', dateTo: '2026-03-20' },
62+
})
63+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
64+
const program = createProgram()
65+
66+
await program.parseAsync(['node', 'tw', 'away'])
67+
68+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Vacation'))
69+
logSpy.mockRestore()
70+
})
71+
})
72+
73+
describe('set', () => {
74+
it('calls users.update with awayMode', async () => {
75+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
76+
const program = createProgram()
77+
78+
await program.parseAsync(['node', 'tw', 'away', 'set', 'vacation', '2026-03-20'])
79+
80+
expect(apiMocks.updateUser).toHaveBeenCalledWith(
81+
expect.objectContaining({
82+
awayMode: expect.objectContaining({
83+
type: 'vacation',
84+
dateTo: '2026-03-20',
85+
}),
86+
}),
87+
)
88+
logSpy.mockRestore()
89+
})
90+
91+
it('supports --from flag', async () => {
92+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
93+
const program = createProgram()
94+
95+
await program.parseAsync([
96+
'node',
97+
'tw',
98+
'away',
99+
'set',
100+
'vacation',
101+
'2026-03-20',
102+
'--from',
103+
'2026-03-15',
104+
])
105+
106+
expect(apiMocks.updateUser).toHaveBeenCalledWith({
107+
awayMode: { type: 'vacation', dateFrom: '2026-03-15', dateTo: '2026-03-20' },
108+
})
109+
logSpy.mockRestore()
110+
})
111+
112+
it('shows dry-run message', async () => {
113+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
114+
const program = createProgram()
115+
116+
await program.parseAsync([
117+
'node',
118+
'tw',
119+
'away',
120+
'set',
121+
'vacation',
122+
'2026-03-20',
123+
'--dry-run',
124+
])
125+
126+
expect(apiMocks.updateUser).not.toHaveBeenCalled()
127+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Dry run'))
128+
logSpy.mockRestore()
129+
})
130+
131+
it('shows friendly error on insufficient scope (403)', async () => {
132+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
133+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
134+
throw new Error('process.exit')
135+
})
136+
apiMocks.updateUser.mockRejectedValue(
137+
new TwistRequestError('Request failed with status 403', 403, {
138+
error_code: 109,
139+
error_string: 'Insufficient scope provided: user:write',
140+
}),
141+
)
142+
const program = createProgram()
143+
144+
await expect(
145+
program.parseAsync(['node', 'tw', 'away', 'set', 'vacation', '2026-03-20']),
146+
).rejects.toThrow()
147+
148+
expect(errorSpy).toHaveBeenCalledWith(
149+
expect.anything(),
150+
'The away status feature requires additional permissions.',
151+
)
152+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('tw auth login'))
153+
errorSpy.mockRestore()
154+
exitSpy.mockRestore()
155+
})
156+
157+
it('rejects invalid away type', async () => {
158+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
159+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
160+
throw new Error('process.exit')
161+
})
162+
const program = createProgram()
163+
164+
await expect(
165+
program.parseAsync(['node', 'tw', 'away', 'set', 'invalid', '2026-03-20']),
166+
).rejects.toThrow()
167+
168+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid away type'))
169+
errorSpy.mockRestore()
170+
exitSpy.mockRestore()
171+
})
172+
})
173+
174+
describe('clear', () => {
175+
it('calls users.update with empty string awayMode to clear', async () => {
176+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
177+
const program = createProgram()
178+
179+
await program.parseAsync(['node', 'tw', 'away', 'clear'])
180+
181+
expect(apiMocks.updateUser).toHaveBeenCalledWith({ awayMode: '' })
182+
expect(logSpy).toHaveBeenCalledWith('Away status cleared.')
183+
logSpy.mockRestore()
184+
})
185+
186+
it('shows dry-run message', async () => {
187+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
188+
const program = createProgram()
189+
190+
await program.parseAsync(['node', 'tw', 'away', 'clear', '--dry-run'])
191+
192+
expect(apiMocks.updateUser).not.toHaveBeenCalled()
193+
expect(logSpy).toHaveBeenCalledWith('Dry run: would clear away status')
194+
logSpy.mockRestore()
195+
})
196+
})
197+
})

src/commands/away.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { AWAY_MODE_TYPES, type AwayModeType, TwistRequestError } from '@doist/twist-sdk'
2+
import chalk from 'chalk'
3+
import { Command } from 'commander'
4+
import { getSessionUser, getTwistClient } from '../lib/api.js'
5+
import type { MutationOptions, ViewOptions } from '../lib/options.js'
6+
import { colors, formatJson } from '../lib/output.js'
7+
8+
type SetAwayOptions = ViewOptions & MutationOptions & { from?: string }
9+
10+
function formatLocalDate(d: Date): string {
11+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
12+
}
13+
14+
function todayStr(): string {
15+
return formatLocalDate(new Date())
16+
}
17+
18+
function tomorrowStr(): string {
19+
const d = new Date()
20+
d.setDate(d.getDate() + 1)
21+
return formatLocalDate(d)
22+
}
23+
24+
function formatAwayType(type: string): string {
25+
const labels: Record<string, string> = {
26+
vacation: 'Vacation',
27+
parental: 'Parental leave',
28+
sickleave: 'Sick leave',
29+
other: 'Away',
30+
}
31+
return labels[type] ?? type
32+
}
33+
34+
async function showAwayStatus(options: ViewOptions): Promise<void> {
35+
const user = await getSessionUser()
36+
37+
if (options.json) {
38+
console.log(formatJson(user, 'user', options.full))
39+
return
40+
}
41+
42+
if (!user.awayMode) {
43+
console.log('Not away.')
44+
return
45+
}
46+
47+
const { type, dateFrom, dateTo } = user.awayMode
48+
console.log(chalk.bold(formatAwayType(type)))
49+
console.log(`From: ${colors.timestamp(dateFrom)}`)
50+
console.log(`Until: ${colors.timestamp(dateTo)}`)
51+
}
52+
53+
async function setAway(
54+
type: string,
55+
until: string | undefined,
56+
options: SetAwayOptions,
57+
): Promise<void> {
58+
if (!AWAY_MODE_TYPES.includes(type as AwayModeType)) {
59+
console.error(`Invalid away type: ${type}. Use: ${AWAY_MODE_TYPES.join(', ')}`)
60+
process.exit(1)
61+
}
62+
63+
const dateFrom = options.from ?? todayStr()
64+
const dateTo = until ?? tomorrowStr()
65+
66+
if (options.dryRun) {
67+
console.log(
68+
`Dry run: would set away to ${formatAwayType(type)} from ${dateFrom} until ${dateTo}`,
69+
)
70+
return
71+
}
72+
73+
const client = await getTwistClient()
74+
try {
75+
const user = await client.users.update({
76+
awayMode: { type: type as AwayModeType, dateFrom, dateTo },
77+
})
78+
79+
if (options.json) {
80+
console.log(formatJson(user, 'user', options.full))
81+
return
82+
}
83+
84+
console.log(`Set away: ${formatAwayType(type)} from ${dateFrom} until ${dateTo}`)
85+
} catch (error) {
86+
handleAwayError(error)
87+
}
88+
}
89+
90+
async function clearAway(options: MutationOptions & ViewOptions): Promise<void> {
91+
if (options.dryRun) {
92+
console.log('Dry run: would clear away status')
93+
return
94+
}
95+
96+
const client = await getTwistClient()
97+
try {
98+
const user = await client.users.update({ awayMode: '' as never })
99+
100+
if (options.json) {
101+
console.log(formatJson(user, 'user', options.full))
102+
return
103+
}
104+
105+
console.log('Away status cleared.')
106+
} catch (error) {
107+
handleAwayError(error)
108+
}
109+
}
110+
111+
function isInsufficientScope(error: unknown): boolean {
112+
if (!(error instanceof TwistRequestError)) return false
113+
const data = error.responseData as { error_string?: string } | undefined
114+
return (
115+
error.httpStatusCode === 403 && data?.error_string?.includes('Insufficient scope') === true
116+
)
117+
}
118+
119+
function handleAwayError(error: unknown): never {
120+
if (isInsufficientScope(error)) {
121+
console.error(
122+
chalk.red('Permission denied.'),
123+
'The away status feature requires additional permissions.',
124+
)
125+
console.error(
126+
`Run ${chalk.cyan('tw auth login')} to re-authenticate with the required scopes.`,
127+
)
128+
process.exit(1)
129+
}
130+
throw error
131+
}
132+
133+
export function registerAwayCommand(program: Command): void {
134+
const away = program
135+
.command('away')
136+
.description('Manage away status')
137+
.option('--json', 'Output as JSON')
138+
.option('--full', 'Include all fields in JSON output')
139+
.action((options: ViewOptions) => showAwayStatus(options))
140+
141+
away.command('set <type> [until]')
142+
.usage('<type> [until] [options]')
143+
.description('Set away status (type: vacation, parental, sickleave, other)')
144+
.option('--from <date>', 'Start date (YYYY-MM-DD, defaults to today)')
145+
.option('--dry-run', 'Show what would happen without executing')
146+
.option('--json', 'Output as JSON')
147+
.option('--full', 'Include all fields in JSON output')
148+
.action((type: string, until: string | undefined, options: SetAwayOptions) =>
149+
setAway(type, until, options),
150+
)
151+
152+
away.command('clear')
153+
.description('Clear away status')
154+
.option('--dry-run', 'Show what would happen without executing')
155+
.option('--json', 'Output as JSON')
156+
.option('--full', 'Include all fields in JSON output')
157+
.action((options: MutationOptions & ViewOptions) => clearAway(options))
158+
}

0 commit comments

Comments
 (0)