Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions skills/twist-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ tw workspaces # List available workspaces
tw workspace use <ref> # Set current workspace
tw completion install # Install shell completions
tw config view # Show the current CLI configuration file (token masked)
tw config set <key> <value> # Set a user preference (e.g. unarchive-new-threads true)
tw doctor # Diagnose CLI setup and environment issues
tw update # Update CLI to latest version
tw changelog # Show recent changelog entries
Expand Down Expand Up @@ -75,6 +76,8 @@ tw thread create <channel-ref> "Title" "content" # Create a new thread
tw thread create <channel-ref> "Title" "content" --json # Create and return as JSON
tw thread create <channel-ref> "Title" "content" --json --full # Include all thread fields
tw thread create <channel-ref> "Title" "content" --notify 123,456 # Notify specific users
tw thread create <channel-ref> "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Twist auto-archive)
tw thread create <channel-ref> "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true
tw thread create <channel-ref> "Title" "content" --dry-run # Preview without posting
tw thread reply <ref> "content" # Post a comment (notifies EVERYONE_IN_THREAD by default)
tw thread reply <ref> "content" --notify EVERYONE # Notify all workspace members
Expand Down Expand Up @@ -255,8 +258,12 @@ tw doctor --json # JSON output with per-check results
tw config view # Pretty-printed config, token masked, labels actual token source
tw config view --json # Raw JSON, token masked
tw config view --show-token # Include the full token
tw config set unarchive-new-threads true # Persist: always unarchive new threads so they land in your Inbox
tw config set unarchive-new-threads false # Persist: keep Twist's default (thread auto-archived for author)
```

User preferences are stored under `userSettings` in the config file. Currently supported keys: `unarchive-new-threads`. The flag on `tw thread create` (`--unarchive` / `--no-unarchive`) overrides this default per-invocation.

### Update

```bash
Expand Down
154 changes: 153 additions & 1 deletion src/commands/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ vi.mock('../../lib/config.js', async (importOriginal) => {
...actual,
CONFIG_PATH: '/tmp/fake-twist-cli/config.json',
readConfigStrict: vi.fn(),
setConfig: vi.fn(),
}
})

Expand All @@ -21,13 +22,14 @@ vi.mock('../../lib/auth.js', async (importOriginal) => {
})

import { NoTokenError, probeApiToken } from '../../lib/auth.js'
import { type Config, readConfigStrict } from '../../lib/config.js'
import { type Config, readConfigStrict, setConfig } from '../../lib/config.js'
import { CliError } from '../../lib/errors.js'
import { SecureStoreUnavailableError } from '../../lib/secure-store.js'
import { registerConfigCommand } from './index.js'

const mockReadConfigStrict = vi.mocked(readConfigStrict)
const mockProbeApiToken = vi.mocked(probeApiToken)
const mockSetConfig = vi.mocked(setConfig)

function createProgram() {
const program = new Command()
Expand Down Expand Up @@ -270,4 +272,154 @@ describe('config view', () => {

consoleSpy.mockRestore()
})

it('shows the user settings section', async () => {
presentConfig({ userSettings: { unarchiveNewThreads: true } })
mockProbeApiToken.mockRejectedValue(new NoTokenError())
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await createProgram().parseAsync(['node', 'tw', 'config', 'view'])

const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n')
expect(output).toContain('User settings')
expect(output).toContain('Unarchive new threads')
expect(output).toContain('true')
consoleSpy.mockRestore()
})
})

describe('config set', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSetConfig.mockResolvedValue()
})

it('writes userSettings.unarchiveNewThreads = true', async () => {
mockReadConfigStrict.mockResolvedValue({ state: 'present', config: {} })
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await createProgram().parseAsync([
'node',
'tw',
'config',
'set',
'unarchive-new-threads',
'true',
])

expect(mockSetConfig).toHaveBeenCalledWith({
userSettings: { unarchiveNewThreads: true },
})
const output = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n')
expect(output).toContain('userSettings.unarchiveNewThreads = true')
consoleSpy.mockRestore()
})

it('writes false for off/0/no', async () => {
mockReadConfigStrict.mockResolvedValue({
state: 'present',
config: { userSettings: { unarchiveNewThreads: true } },
})
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await createProgram().parseAsync([
'node',
'tw',
'config',
'set',
'unarchive-new-threads',
'off',
])

expect(mockSetConfig).toHaveBeenCalledWith({
userSettings: { unarchiveNewThreads: false },
})
consoleSpy.mockRestore()
})

it('preserves other userSettings keys when updating', async () => {
mockReadConfigStrict.mockResolvedValue({
state: 'present',
config: {
userSettings: { unarchiveNewThreads: false },
currentWorkspace: 7,
} as Config,
})
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await createProgram().parseAsync([
'node',
'tw',
'config',
'set',
'unarchive-new-threads',
'true',
])

expect(mockSetConfig).toHaveBeenCalledWith({
userSettings: { unarchiveNewThreads: true },
currentWorkspace: 7,
})
consoleSpy.mockRestore()
})

it('rejects unknown keys', async () => {
mockReadConfigStrict.mockResolvedValue({ state: 'present', config: {} })

await expect(
createProgram().parseAsync(['node', 'tw', 'config', 'set', 'nope', 'true']),
).rejects.toBeInstanceOf(CliError)
expect(mockSetConfig).not.toHaveBeenCalled()
})

it('rejects invalid boolean values', async () => {
mockReadConfigStrict.mockResolvedValue({ state: 'present', config: {} })

await expect(
createProgram().parseAsync([
'node',
'tw',
'config',
'set',
'unarchive-new-threads',
'maybe',
]),
).rejects.toMatchObject({ code: 'INVALID_VALUE' })
expect(mockSetConfig).not.toHaveBeenCalled()
})

it('writes a fresh config when the file is missing', async () => {
mockReadConfigStrict.mockResolvedValue({ state: 'missing' })
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await createProgram().parseAsync([
'node',
'tw',
'config',
'set',
'unarchive-new-threads',
'true',
])

expect(mockSetConfig).toHaveBeenCalledWith({
userSettings: { unarchiveNewThreads: true },
})
consoleSpy.mockRestore()
})

it('refuses to overwrite a malformed config file', async () => {
mockReadConfigStrict.mockRejectedValue(new CliError('CONFIG_INVALID_JSON', 'broken'))

await expect(
createProgram().parseAsync([
'node',
'tw',
'config',
'set',
'unarchive-new-threads',
'true',
]),
).rejects.toMatchObject({ code: 'CONFIG_INVALID_JSON' })
expect(mockSetConfig).not.toHaveBeenCalled()
})
})
23 changes: 20 additions & 3 deletions src/commands/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from 'commander'
import { listSettableKeys, setConfigValue } from './set.js'
import { viewConfig } from './view.js'

export function registerConfigCommand(program: Command): void {
Expand All @@ -11,12 +12,28 @@ export function registerConfigCommand(program: Command): void {
.option('--show-token', 'Include the full token instead of masking it')
.action(viewConfig)

config
.command('set <key> <value>')
.description('Set a user preference in the config file')
.addHelpText(
'after',
`
Settable keys:
${listSettableKeys()}

Examples:
$ tw config set unarchive-new-threads true
$ tw config set unarchive-new-threads false`,
)
.action(setConfigValue)

config.addHelpText(
'after',
`
Examples:
$ tw config view # pretty-printed, token masked
$ tw config view --json # raw JSON, token masked
$ tw config view --show-token # include the full token`,
$ tw config view # pretty-printed, token masked
$ tw config view --json # raw JSON, token masked
$ tw config view --show-token # include the full token
$ tw config set unarchive-new-threads true # change a user preference`,
)
}
50 changes: 50 additions & 0 deletions src/commands/config/set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import chalk from 'chalk'
import { type Config, readConfigStrict, setConfig } from '../../lib/config.js'
import { CliError } from '../../lib/errors.js'

const TRUE_VALUES = new Set(['true', 'on', '1', 'yes'])
const FALSE_VALUES = new Set(['false', 'off', '0', 'no'])

function parseBoolean(raw: string, key: string): boolean {
const normalized = raw.trim().toLowerCase()
if (TRUE_VALUES.has(normalized)) return true
if (FALSE_VALUES.has(normalized)) return false
throw new CliError(
'INVALID_VALUE',
Comment thread
scottlovegrove marked this conversation as resolved.
`Invalid boolean value "${raw}" for ${key}. Use one of: true, false, on, off, 1, 0.`,
)
}

type Setter = (config: Config, value: string) => string

const SETTERS: Record<string, { description: string; apply: Setter }> = {
'unarchive-new-threads': {
description: 'Unarchive newly-created threads so they appear in your Inbox',
apply: (config, value) => {
const parsed = parseBoolean(value, 'unarchive-new-threads')
config.userSettings = { ...config.userSettings, unarchiveNewThreads: parsed }
return `userSettings.unarchiveNewThreads = ${parsed}`
},
},
}

export async function setConfigValue(key: string, value: string): Promise<void> {
const setter = SETTERS[key]
if (!setter) {
const known = Object.keys(SETTERS).join(', ')
throw new CliError('UNKNOWN_KEY', `Unknown config key "${key}". Known keys: ${known}.`)
}

const read = await readConfigStrict()
const config: Config = read.state === 'present' ? read.config : {}
const summary = setter.apply(config, value)
await setConfig(config)

console.log(chalk.green('✓'), `Set ${chalk.cyan(summary)}`)
}

export function listSettableKeys(): string {
return Object.entries(SETTERS)
.map(([key, { description }]) => ` ${key.padEnd(28)} ${description}`)
.join('\n')
}
4 changes: 4 additions & 0 deletions src/commands/config/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ function formatConfigView(

lines.push(chalk.bold('Updates'))
lines.push(` Channel: ${formatValue(config.updateChannel)}`)
lines.push('')

lines.push(chalk.bold('User settings'))
lines.push(` Unarchive new threads: ${formatValue(config.userSettings?.unarchiveNewThreads)}`)

return lines.join('\n')
}
Expand Down
15 changes: 15 additions & 0 deletions src/commands/thread/create.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getTwistClient } from '../../lib/api.js'
import { getConfig } from '../../lib/config.js'
import { CliError } from '../../lib/errors.js'
import { openEditor, readStdin } from '../../lib/input.js'
import type { MutationOptions } from '../../lib/options.js'
Expand All @@ -9,6 +10,7 @@ import { type ResolvedNotify, formatNotifyLabel, resolveNotifyIds } from './help

type CreateOptions = MutationOptions & {
notify?: string
unarchive?: boolean
}

export async function createThread(
Expand Down Expand Up @@ -44,6 +46,9 @@ export async function createThread(
resolved = await resolveNotifyIds(allIds, channel.workspaceId)
}

const config = await getConfig()
const shouldUnarchive = options.unarchive ?? config.userSettings?.unarchiveNewThreads ?? false

if (options.dryRun) {
const preview =
threadContent.length > 200 ? `${threadContent.slice(0, 200)}...` : threadContent
Expand All @@ -58,6 +63,7 @@ export async function createThread(
resolved && resolved.notified.groups.length > 0
? formatNotifyLabel(resolved.notified.groups)
: undefined,
Unarchive: shouldUnarchive ? 'yes' : undefined,
Content: preview,
})
return
Expand All @@ -71,6 +77,15 @@ export async function createThread(
groups: resolved?.groups,
})

if (shouldUnarchive) {
try {
await client.inbox.unarchiveThread(thread.id)
Comment thread
scottlovegrove marked this conversation as resolved.
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
console.error(`Warning: created thread but failed to unarchive it (${detail})`)
}
}

if (options.json) {
const output = resolved ? { ...thread, notified: resolved.notified } : thread
console.log(formatJson(output, 'thread', options.full))
Expand Down
8 changes: 7 additions & 1 deletion src/commands/thread/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ Examples:
.command('create <channel-ref> <title> [content]')
.description('Create a new thread in a channel')
.option('--notify <recipients>', 'Comma-separated user IDs to notify')
.option(
'--unarchive',
'Unarchive after creation so the thread appears in your Inbox (overrides userSettings.unarchiveNewThreads when false)',
)
.option('--no-unarchive', 'Skip unarchive even if userSettings.unarchiveNewThreads is true')
.option('--dry-run', 'Show what would be posted without posting')
.option('--json', 'Output created thread as JSON')
.option('--full', 'Include all fields in JSON output')
Expand All @@ -81,7 +86,8 @@ Examples:
Examples:
tw thread create 12345 "Weekly update" "Here's what happened..."
echo "Body from stdin" | tw thread create id:12345 "Title"
tw thread create 12345 "Title" "Body" --notify 67890,11111 --json`,
tw thread create 12345 "Title" "Body" --notify 67890,11111 --json
tw thread create 12345 "Title" "Body" --unarchive`,
)
.action(createThread)

Expand Down
Loading
Loading