From f6e143c3b07cc76f18bbabb3f648b56af0562e8d Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Mon, 27 Apr 2026 10:25:04 +0100 Subject: [PATCH 1/2] feat(thread): opt-in unarchive of newly-created threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twist auto-archives a newly-created thread for its author so it does not appear in the author's Inbox. Add an opt-in `--unarchive` flag on `tw thread create` that calls `inbox.unarchiveThread` after creation, plus a persisted `userSettings.unarchiveNewThreads` preference settable via `tw config set unarchive-new-threads `. The flag wins over the config value, so `--no-unarchive` can override a persisted default. Default behaviour is unchanged — thread stays archived for the author unless the user opts in. Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/twist-cli/SKILL.md | 7 ++ src/commands/config/config.test.ts | 115 ++++++++++++++++++++++++++++- src/commands/config/index.ts | 23 +++++- src/commands/config/set.ts | 49 ++++++++++++ src/commands/config/view.ts | 4 + src/commands/thread/create.ts | 15 ++++ src/commands/thread/index.ts | 8 +- src/commands/thread/thread.test.ts | 114 ++++++++++++++++++++++++++++ src/lib/config.test.ts | 42 +++++++++++ src/lib/config.ts | 32 ++++++++ src/lib/skills/content.ts | 7 ++ 11 files changed, 411 insertions(+), 5 deletions(-) create mode 100644 src/commands/config/set.ts create mode 100644 src/lib/config.test.ts diff --git a/skills/twist-cli/SKILL.md b/skills/twist-cli/SKILL.md index dd55ff3..2218d0b 100644 --- a/skills/twist-cli/SKILL.md +++ b/skills/twist-cli/SKILL.md @@ -24,6 +24,7 @@ tw workspaces # List available workspaces tw workspace use # Set current workspace tw completion install # Install shell completions tw config view # Show the current CLI configuration file (token masked) +tw config set # Set a user preference (e.g. unarchive-new-threads true) tw doctor # Diagnose CLI setup and environment issues tw update # Update CLI to latest version tw changelog # Show recent changelog entries @@ -75,6 +76,8 @@ tw thread create "Title" "content" # Create a new thread tw thread create "Title" "content" --json # Create and return as JSON tw thread create "Title" "content" --json --full # Include all thread fields tw thread create "Title" "content" --notify 123,456 # Notify specific users +tw thread create "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Twist auto-archive) +tw thread create "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true tw thread create "Title" "content" --dry-run # Preview without posting tw thread reply "content" # Post a comment (notifies EVERYONE_IN_THREAD by default) tw thread reply "content" --notify EVERYONE # Notify all workspace members @@ -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 diff --git a/src/commands/config/config.test.ts b/src/commands/config/config.test.ts index 104f40c..8e70d48 100644 --- a/src/commands/config/config.test.ts +++ b/src/commands/config/config.test.ts @@ -9,6 +9,8 @@ vi.mock('../../lib/config.js', async (importOriginal) => { ...actual, CONFIG_PATH: '/tmp/fake-twist-cli/config.json', readConfigStrict: vi.fn(), + getConfig: vi.fn(), + setConfig: vi.fn(), } }) @@ -21,13 +23,15 @@ 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, getConfig, 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 mockGetConfig = vi.mocked(getConfig) +const mockSetConfig = vi.mocked(setConfig) function createProgram() { const program = new Command() @@ -270,4 +274,113 @@ 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 () => { + mockGetConfig.mockResolvedValue({}) + 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 () => { + mockGetConfig.mockResolvedValue({ 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 () => { + mockGetConfig.mockResolvedValue({ + 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 () => { + mockGetConfig.mockResolvedValue({}) + + await expect( + createProgram().parseAsync(['node', 'tw', 'config', 'set', 'nope', 'true']), + ).rejects.toBeInstanceOf(CliError) + expect(mockSetConfig).not.toHaveBeenCalled() + }) + + it('rejects invalid boolean values', async () => { + mockGetConfig.mockResolvedValue({}) + + await expect( + createProgram().parseAsync([ + 'node', + 'tw', + 'config', + 'set', + 'unarchive-new-threads', + 'maybe', + ]), + ).rejects.toMatchObject({ code: 'INVALID_VALUE' }) + expect(mockSetConfig).not.toHaveBeenCalled() + }) }) diff --git a/src/commands/config/index.ts b/src/commands/config/index.ts index 775f6f2..0112960 100644 --- a/src/commands/config/index.ts +++ b/src/commands/config/index.ts @@ -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 { @@ -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 ') + .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`, ) } diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts new file mode 100644 index 0000000..e0d00b1 --- /dev/null +++ b/src/commands/config/set.ts @@ -0,0 +1,49 @@ +import chalk from 'chalk' +import { getConfig, setConfig, type UserSettings } 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', + `Invalid boolean value "${raw}" for ${key}. Use one of: true, false, on, off, 1, 0.`, + ) +} + +type Setter = (config: { userSettings?: UserSettings }, value: string) => string + +const SETTERS: Record = { + '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 { + 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 config = await getConfig() + 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') +} diff --git a/src/commands/config/view.ts b/src/commands/config/view.ts index 23abfa1..307a472 100644 --- a/src/commands/config/view.ts +++ b/src/commands/config/view.ts @@ -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') } diff --git a/src/commands/thread/create.ts b/src/commands/thread/create.ts index 4ddff1c..d456dba 100644 --- a/src/commands/thread/create.ts +++ b/src/commands/thread/create.ts @@ -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' @@ -9,6 +10,7 @@ import { type ResolvedNotify, formatNotifyLabel, resolveNotifyIds } from './help type CreateOptions = MutationOptions & { notify?: string + unarchive?: boolean } export async function createThread( @@ -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 @@ -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 @@ -71,6 +77,15 @@ export async function createThread( groups: resolved?.groups, }) + if (shouldUnarchive) { + try { + await client.inbox.unarchiveThread(thread.id) + } 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)) diff --git a/src/commands/thread/index.ts b/src/commands/thread/index.ts index f1c40f1..f62ca48 100644 --- a/src/commands/thread/index.ts +++ b/src/commands/thread/index.ts @@ -72,6 +72,11 @@ Examples: .command('create [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') @@ -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) diff --git a/src/commands/thread/thread.test.ts b/src/commands/thread/thread.test.ts index 27ac41e..9d70cee 100644 --- a/src/commands/thread/thread.test.ts +++ b/src/commands/thread/thread.test.ts @@ -5,6 +5,15 @@ const apiMocks = vi.hoisted(() => ({ getTwistClient: vi.fn(), })) +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn().mockResolvedValue({}), +})) + +vi.mock('../../lib/config.js', async (importOriginal) => ({ + ...(await importOriginal<typeof import('../../lib/config.js')>()), + getConfig: configMocks.getConfig, +})) + vi.mock('../../lib/public-channels.js', () => ({ assertChannelIsPublic: vi.fn(), })) @@ -138,6 +147,7 @@ function createClient({ }, inbox: { archiveThread: vi.fn(async () => undefined), + unarchiveThread: vi.fn(async () => undefined), }, workspaceUsers: { getUserById: vi.fn( @@ -522,6 +532,7 @@ describe('thread view with failed batch response', () => { describe('thread create', () => { beforeEach(() => { vi.clearAllMocks() + configMocks.getConfig.mockResolvedValue({}) }) it('creates a thread with positional title and content', async () => { @@ -701,6 +712,109 @@ describe('thread create', () => { program.parseAsync(['node', 'tw', 'thread', 'create', '100', 'My Title']), ).rejects.toHaveProperty('code', 'MISSING_CONTENT') }) + + it('does not unarchive by default', async () => { + const client = createClient() + apiMocks.getTwistClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'thread', 'create', '100', 'T', 'body']) + + expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('unarchives the new thread when --unarchive is passed', async () => { + const client = createClient() + apiMocks.getTwistClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'thread', + 'create', + '100', + 'T', + 'body', + '--unarchive', + ]) + + expect(client.inbox.unarchiveThread).toHaveBeenCalledWith(999) + consoleSpy.mockRestore() + }) + + it('unarchives when userSettings.unarchiveNewThreads is true', async () => { + configMocks.getConfig.mockResolvedValueOnce({ + userSettings: { unarchiveNewThreads: true }, + }) + const client = createClient() + apiMocks.getTwistClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'thread', 'create', '100', 'T', 'body']) + + expect(client.inbox.unarchiveThread).toHaveBeenCalledWith(999) + consoleSpy.mockRestore() + }) + + it('--no-unarchive overrides config default of true', async () => { + configMocks.getConfig.mockResolvedValueOnce({ + userSettings: { unarchiveNewThreads: true }, + }) + const client = createClient() + apiMocks.getTwistClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'thread', + 'create', + '100', + 'T', + 'body', + '--no-unarchive', + ]) + + expect(client.inbox.unarchiveThread).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('unarchive failure does not fail the command', async () => { + const client = createClient() + client.inbox.unarchiveThread.mockRejectedValueOnce(new Error('boom')) + apiMocks.getTwistClient.mockResolvedValue(client) + + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'thread', + 'create', + '100', + 'T', + 'body', + '--unarchive', + ]) + + expect(client.threads.createThread).toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('failed to unarchive')) + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Thread created:')) + consoleSpy.mockRestore() + errorSpy.mockRestore() + }) }) describe('thread mute', () => { diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts new file mode 100644 index 0000000..3389aab --- /dev/null +++ b/src/lib/config.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { validateConfigForDoctor } from './config.js' + +describe('validateConfigForDoctor', () => { + it('accepts an empty config', () => { + expect(validateConfigForDoctor({})).toEqual([]) + }) + + it('accepts a valid userSettings.unarchiveNewThreads', () => { + expect(validateConfigForDoctor({ userSettings: { unarchiveNewThreads: true } })).toEqual([]) + expect(validateConfigForDoctor({ userSettings: { unarchiveNewThreads: false } })).toEqual( + [], + ) + expect(validateConfigForDoctor({ userSettings: {} })).toEqual([]) + }) + + it('rejects non-boolean unarchiveNewThreads', () => { + const issues = validateConfigForDoctor({ + userSettings: { unarchiveNewThreads: 'yes' }, + }) + expect(issues).toContain('userSettings.unarchiveNewThreads must be a boolean') + }) + + it('rejects unknown nested keys under userSettings', () => { + const issues = validateConfigForDoctor({ + userSettings: { somethingElse: 1 }, + }) + expect(issues).toContain('userSettings contains unrecognized key "somethingElse"') + }) + + it('rejects userSettings that is not an object', () => { + expect(validateConfigForDoctor({ userSettings: true })).toContain( + 'userSettings must be an object', + ) + expect(validateConfigForDoctor({ userSettings: [] })).toContain( + 'userSettings must be an object', + ) + expect(validateConfigForDoctor({ userSettings: null })).toContain( + 'userSettings must be an object', + ) + }) +}) diff --git a/src/lib/config.ts b/src/lib/config.ts index 637fc4a..ca21722 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -15,11 +15,18 @@ const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([ 'authMode', 'authScope', 'updateChannel', + 'userSettings', ]) +const KNOWN_USER_SETTINGS_KEYS: ReadonlySet<string> = new Set(['unarchiveNewThreads']) + const AUTH_MODES: ReadonlySet<AuthMode> = new Set(['read-only', 'read-write', 'unknown']) const UPDATE_CHANNELS: ReadonlySet<UpdateChannel> = new Set(['stable', 'pre-release']) +export interface UserSettings { + unarchiveNewThreads?: boolean +} + export interface Config { // Legacy plaintext token storage retained for migration and secure-store fallback only. token?: string @@ -30,6 +37,7 @@ export interface Config { authMode?: AuthMode authScope?: string updateChannel?: UpdateChannel + userSettings?: UserSettings } export async function getConfig(): Promise<Config> { @@ -151,6 +159,30 @@ export function validateConfigForDoctor(config: Record<string, unknown>): string issues.push('updateChannel must be one of: stable, pre-release') } + if (config.userSettings !== undefined) { + const userSettings = config.userSettings + if ( + userSettings === null || + typeof userSettings !== 'object' || + Array.isArray(userSettings) + ) { + issues.push('userSettings must be an object') + } else { + const settingsRecord = userSettings as Record<string, unknown> + for (const key of Object.keys(settingsRecord)) { + if (!KNOWN_USER_SETTINGS_KEYS.has(key)) { + issues.push(`userSettings contains unrecognized key "${key}"`) + } + } + if ( + settingsRecord.unarchiveNewThreads !== undefined && + typeof settingsRecord.unarchiveNewThreads !== 'boolean' + ) { + issues.push('userSettings.unarchiveNewThreads must be a boolean') + } + } + } + return issues } diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 9506af6..9d79956 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -28,6 +28,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 @@ -79,6 +80,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 @@ -259,8 +262,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 From 486642d16406e1066c70b0015d8ff141904367de Mon Sep 17 00:00:00 2001 From: Scott Lovegrove <scott@ferretlabs.com> Date: Mon, 27 Apr 2026 11:00:05 +0100 Subject: [PATCH 2/2] fix(config): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `inbox.unarchiveThread` to API_SPINNER_MESSAGES so users see progress feedback during the unarchive call. - Register `INVALID_VALUE` and `UNKNOWN_KEY` in the ErrorCode union. - Switch `tw config set` from `getConfig` (which silently treats parse failures as empty) to `readConfigStrict`, so a malformed config file surfaces the error instead of being overwritten — preserving any existing token / workspace / future fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- src/commands/config/config.test.ts | 61 ++++++++++++++++++++++++------ src/commands/config/set.ts | 7 ++-- src/lib/api.ts | 1 + src/lib/errors.ts | 2 + 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/commands/config/config.test.ts b/src/commands/config/config.test.ts index 8e70d48..b96a3dc 100644 --- a/src/commands/config/config.test.ts +++ b/src/commands/config/config.test.ts @@ -9,7 +9,6 @@ vi.mock('../../lib/config.js', async (importOriginal) => { ...actual, CONFIG_PATH: '/tmp/fake-twist-cli/config.json', readConfigStrict: vi.fn(), - getConfig: vi.fn(), setConfig: vi.fn(), } }) @@ -23,14 +22,13 @@ vi.mock('../../lib/auth.js', async (importOriginal) => { }) import { NoTokenError, probeApiToken } from '../../lib/auth.js' -import { type Config, getConfig, readConfigStrict, setConfig } 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 mockGetConfig = vi.mocked(getConfig) const mockSetConfig = vi.mocked(setConfig) function createProgram() { @@ -297,7 +295,7 @@ describe('config set', () => { }) it('writes userSettings.unarchiveNewThreads = true', async () => { - mockGetConfig.mockResolvedValue({}) + mockReadConfigStrict.mockResolvedValue({ state: 'present', config: {} }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await createProgram().parseAsync([ @@ -318,7 +316,10 @@ describe('config set', () => { }) it('writes false for off/0/no', async () => { - mockGetConfig.mockResolvedValue({ userSettings: { unarchiveNewThreads: true } }) + mockReadConfigStrict.mockResolvedValue({ + state: 'present', + config: { userSettings: { unarchiveNewThreads: true } }, + }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await createProgram().parseAsync([ @@ -337,10 +338,13 @@ describe('config set', () => { }) it('preserves other userSettings keys when updating', async () => { - mockGetConfig.mockResolvedValue({ - userSettings: { unarchiveNewThreads: false }, - currentWorkspace: 7, - } as Config) + mockReadConfigStrict.mockResolvedValue({ + state: 'present', + config: { + userSettings: { unarchiveNewThreads: false }, + currentWorkspace: 7, + } as Config, + }) const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await createProgram().parseAsync([ @@ -360,7 +364,7 @@ describe('config set', () => { }) it('rejects unknown keys', async () => { - mockGetConfig.mockResolvedValue({}) + mockReadConfigStrict.mockResolvedValue({ state: 'present', config: {} }) await expect( createProgram().parseAsync(['node', 'tw', 'config', 'set', 'nope', 'true']), @@ -369,7 +373,7 @@ describe('config set', () => { }) it('rejects invalid boolean values', async () => { - mockGetConfig.mockResolvedValue({}) + mockReadConfigStrict.mockResolvedValue({ state: 'present', config: {} }) await expect( createProgram().parseAsync([ @@ -383,4 +387,39 @@ describe('config set', () => { ).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() + }) }) diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts index e0d00b1..68f7fae 100644 --- a/src/commands/config/set.ts +++ b/src/commands/config/set.ts @@ -1,5 +1,5 @@ import chalk from 'chalk' -import { getConfig, setConfig, type UserSettings } from '../../lib/config.js' +import { type Config, readConfigStrict, setConfig } from '../../lib/config.js' import { CliError } from '../../lib/errors.js' const TRUE_VALUES = new Set(['true', 'on', '1', 'yes']) @@ -15,7 +15,7 @@ function parseBoolean(raw: string, key: string): boolean { ) } -type Setter = (config: { userSettings?: UserSettings }, value: string) => string +type Setter = (config: Config, value: string) => string const SETTERS: Record<string, { description: string; apply: Setter }> = { 'unarchive-new-threads': { @@ -35,7 +35,8 @@ export async function setConfigValue(key: string, value: string): Promise<void> throw new CliError('UNKNOWN_KEY', `Unknown config key "${key}". Known keys: ${known}.`) } - const config = await getConfig() + const read = await readConfigStrict() + const config: Config = read.state === 'present' ? read.config : {} const summary = setter.apply(config, value) await setConfig(config) diff --git a/src/lib/api.ts b/src/lib/api.ts index 1ce1f32..7eab14b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -81,6 +81,7 @@ const API_SPINNER_MESSAGES: Record<string, { text: string; color?: 'blue' | 'gre // Inbox operations 'inbox.getInbox': { text: 'Loading inbox...', color: 'blue' }, 'inbox.archiveThread': { text: 'Archiving thread...', color: 'yellow' }, + 'inbox.unarchiveThread': { text: 'Unarchiving thread...', color: 'yellow' }, // Batch operations batch: { text: 'Processing batch operations...', color: 'blue' }, diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 0d1b5fd..5cc49e1 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -21,8 +21,10 @@ export type ErrorCode = | 'INVALID_STATE' | 'INVALID_TYPE' | 'INVALID_URL' + | 'INVALID_VALUE' | 'MISSING_CONTENT' | 'MISSING_YES_FLAG' + | 'UNKNOWN_KEY' // Not found | 'CHANNEL_NOT_FOUND' | 'NOT_FOUND'