WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add config command for managing CLI configuration

Implement generic config management command:
- tangled config list: List all available config keys with descriptions
- tangled config get [key]: View current configuration (all or specific)
- tangled config set <key> <value> [--global]: Set any config value
- tangled config unset <key> [--global]: Clear any config value

The --global flag switches between local (.tangledrc in repo) and
user (~/.tangledrc) config storage. Generic set/unset allows for
future config keys without code changes. The list command helps
users discover available configuration options.

Includes comprehensive test coverage (15 tests) for all operations
and edge cases.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+465
+204
src/commands/config.ts
··· 1 + import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; 2 + import { homedir } from 'node:os'; 3 + import { dirname, join } from 'node:path'; 4 + import { Command } from 'commander'; 5 + import { simpleGit } from 'simple-git'; 6 + import { type TangledConfig, loadConfig } from '../lib/config.js'; 7 + 8 + /** 9 + * Get Git root directory 10 + */ 11 + async function getGitRoot(cwd: string = process.cwd()): Promise<string | null> { 12 + try { 13 + const git = simpleGit(cwd); 14 + const isRepo = await git.checkIsRepo(); 15 + if (!isRepo) { 16 + return null; 17 + } 18 + const root = await git.revparse(['--show-toplevel']); 19 + return root.trim(); 20 + } catch { 21 + return null; 22 + } 23 + } 24 + 25 + /** 26 + * Set a config value 27 + */ 28 + async function setConfigValue(key: string, value: string, global: boolean): Promise<void> { 29 + const configPath = global 30 + ? join(homedir(), '.tangledrc') 31 + : join((await getGitRoot()) || process.cwd(), '.tangledrc'); 32 + 33 + if (!global) { 34 + const gitRoot = await getGitRoot(); 35 + if (!gitRoot) { 36 + throw new Error('Not in a Git repository. Use --global or run from a Git repository.'); 37 + } 38 + } 39 + 40 + // Load existing config 41 + let config: TangledConfig = {}; 42 + try { 43 + const content = await readFile(configPath, 'utf-8'); 44 + config = JSON.parse(content); 45 + } catch { 46 + // Config doesn't exist yet, start with empty object 47 + } 48 + 49 + // Set the value 50 + config[key as keyof TangledConfig] = value as never; 51 + 52 + // Write updated config 53 + await mkdir(dirname(configPath), { recursive: true }); 54 + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); 55 + } 56 + 57 + /** 58 + * Unset a config value 59 + */ 60 + async function unsetConfigValue(key: string, global: boolean): Promise<void> { 61 + const configPath = global 62 + ? join(homedir(), '.tangledrc') 63 + : join((await getGitRoot()) || process.cwd(), '.tangledrc'); 64 + 65 + if (!global) { 66 + const gitRoot = await getGitRoot(); 67 + if (!gitRoot) { 68 + throw new Error('Not in a Git repository. Use --global or run from a Git repository.'); 69 + } 70 + } 71 + 72 + // Load existing config 73 + let config: TangledConfig = {}; 74 + try { 75 + const content = await readFile(configPath, 'utf-8'); 76 + config = JSON.parse(content); 77 + } catch { 78 + // Config doesn't exist, nothing to unset 79 + return; 80 + } 81 + 82 + // Remove the key 83 + delete config[key as keyof TangledConfig]; 84 + 85 + // If config is now empty, delete the file 86 + if (Object.keys(config).length === 0) { 87 + try { 88 + await unlink(configPath); 89 + } catch { 90 + // File might not exist 91 + } 92 + } else { 93 + // Write updated config 94 + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); 95 + } 96 + } 97 + 98 + /** 99 + * Available configuration keys with their descriptions 100 + */ 101 + const AVAILABLE_KEYS: Record<string, string> = { 102 + remote: 'Default Git remote to use when multiple tangled.org remotes exist', 103 + }; 104 + 105 + /** 106 + * Create the config command for managing Tangled CLI configuration 107 + */ 108 + export function createConfigCommand(): Command { 109 + const config = new Command('config'); 110 + config.description('Manage Tangled CLI configuration'); 111 + 112 + // List available config keys 113 + config 114 + .command('list') 115 + .description('List all available configuration keys') 116 + .action(async () => { 117 + try { 118 + const cfg = await loadConfig(); 119 + 120 + console.log('Available configuration keys:\n'); 121 + for (const [key, description] of Object.entries(AVAILABLE_KEYS)) { 122 + const value = cfg[key as keyof TangledConfig]; 123 + const status = value ? `"${value}"` : '(not set)'; 124 + console.log(` ${key}`); 125 + console.log(` ${description}`); 126 + console.log(` Current value: ${status}\n`); 127 + } 128 + } catch (error) { 129 + console.error( 130 + `Failed to list config: ${error instanceof Error ? error.message : 'Unknown error'}` 131 + ); 132 + process.exit(1); 133 + } 134 + }); 135 + 136 + // Get current config 137 + config 138 + .command('get [key]') 139 + .description('Get configuration value (defaults to all)') 140 + .action(async (key?: string) => { 141 + try { 142 + const cfg = await loadConfig(); 143 + 144 + if (!key) { 145 + // Show all config values 146 + const keys = Object.keys(cfg) as Array<keyof TangledConfig>; 147 + if (keys.length === 0) { 148 + console.log('No configuration set'); 149 + return; 150 + } 151 + for (const k of keys) { 152 + console.log(`${k} = ${cfg[k] || '(not set)'}`); 153 + } 154 + } else { 155 + // Show specific key 156 + const value = cfg[key as keyof TangledConfig]; 157 + console.log(`${key} = ${value || '(not set)'}`); 158 + } 159 + } catch (error) { 160 + console.error( 161 + `Failed to get config: ${error instanceof Error ? error.message : 'Unknown error'}` 162 + ); 163 + process.exit(1); 164 + } 165 + }); 166 + 167 + // Set config value 168 + config 169 + .command('set <key> <value>') 170 + .option('-g, --global', 'Save to user config instead of local') 171 + .description('Set a configuration value') 172 + .action(async (key: string, value: string, options: { global?: boolean }) => { 173 + try { 174 + await setConfigValue(key, value, options.global ?? false); 175 + const scope = options.global ? 'user config (~/.tangledrc)' : 'local config (.tangledrc)'; 176 + console.log(`✓ Set ${key} to "${value}" in ${scope}`); 177 + } catch (error) { 178 + console.error( 179 + `Failed to set config: ${error instanceof Error ? error.message : 'Unknown error'}` 180 + ); 181 + process.exit(1); 182 + } 183 + }); 184 + 185 + // Unset config value 186 + config 187 + .command('unset <key>') 188 + .option('-g, --global', 'Clear from user config instead of local') 189 + .description('Clear a configuration value') 190 + .action(async (key: string, options: { global?: boolean }) => { 191 + try { 192 + await unsetConfigValue(key, options.global ?? false); 193 + const scope = options.global ? 'user config' : 'local config'; 194 + console.log(`✓ Cleared ${key} from ${scope}`); 195 + } catch (error) { 196 + console.error( 197 + `Failed to clear config: ${error instanceof Error ? error.message : 'Unknown error'}` 198 + ); 199 + process.exit(1); 200 + } 201 + }); 202 + 203 + return config; 204 + }
+261
tests/commands/config.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import { createConfigCommand } from '../../src/commands/config.js'; 3 + 4 + // Mock modules 5 + vi.mock('node:fs/promises'); 6 + vi.mock('simple-git'); 7 + vi.mock('../../src/lib/config.js'); 8 + 9 + // Import mocked modules 10 + import * as fs from 'node:fs/promises'; 11 + import { simpleGit } from 'simple-git'; 12 + import * as configModule from '../../src/lib/config.js'; 13 + 14 + describe('Config Command', () => { 15 + let consoleLogSpy: ReturnType<typeof vi.fn>; 16 + let consoleErrorSpy: ReturnType<typeof vi.fn>; 17 + 18 + beforeEach(() => { 19 + vi.clearAllMocks(); 20 + 21 + // Mock console methods 22 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 23 + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 24 + 25 + // Mock process.exit to throw so tests don't actually exit 26 + vi.spyOn(process, 'exit').mockImplementation((code) => { 27 + throw new Error(`process.exit(${code})`); 28 + }) as never; 29 + }); 30 + 31 + describe('list command', () => { 32 + it('should list all available config keys with descriptions', async () => { 33 + vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'origin' }); 34 + 35 + const config = createConfigCommand(); 36 + await config.parseAsync(['node', 'test', 'list']); 37 + 38 + expect(consoleLogSpy).toHaveBeenCalledWith('Available configuration keys:\n'); 39 + expect(consoleLogSpy).toHaveBeenCalledWith(' remote'); 40 + expect(consoleLogSpy).toHaveBeenCalledWith( 41 + expect.stringContaining('Default Git remote to use') 42 + ); 43 + expect(consoleLogSpy).toHaveBeenCalledWith( 44 + expect.stringContaining('Current value: "origin"') 45 + ); 46 + }); 47 + 48 + it('should show "(not set)" for unset keys', async () => { 49 + vi.mocked(configModule.loadConfig).mockResolvedValue({}); 50 + 51 + const config = createConfigCommand(); 52 + await config.parseAsync(['node', 'test', 'list']); 53 + 54 + expect(consoleLogSpy).toHaveBeenCalledWith( 55 + expect.stringContaining('Current value: (not set)') 56 + ); 57 + }); 58 + }); 59 + 60 + describe('get command', () => { 61 + it('should show all config values when no key specified', async () => { 62 + vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'origin' }); 63 + 64 + const config = createConfigCommand(); 65 + await config.parseAsync(['node', 'test', 'get']); 66 + 67 + expect(consoleLogSpy).toHaveBeenCalledWith('remote = origin'); 68 + }); 69 + 70 + it('should show "No configuration set" when config is empty', async () => { 71 + vi.mocked(configModule.loadConfig).mockResolvedValue({}); 72 + 73 + const config = createConfigCommand(); 74 + await config.parseAsync(['node', 'test', 'get']); 75 + 76 + expect(consoleLogSpy).toHaveBeenCalledWith('No configuration set'); 77 + }); 78 + 79 + it('should show specific key value', async () => { 80 + vi.mocked(configModule.loadConfig).mockResolvedValue({ remote: 'upstream' }); 81 + 82 + const config = createConfigCommand(); 83 + await config.parseAsync(['node', 'test', 'get', 'remote']); 84 + 85 + expect(consoleLogSpy).toHaveBeenCalledWith('remote = upstream'); 86 + }); 87 + 88 + it('should show "(not set)" for undefined key', async () => { 89 + vi.mocked(configModule.loadConfig).mockResolvedValue({}); 90 + 91 + const config = createConfigCommand(); 92 + await config.parseAsync(['node', 'test', 'get', 'remote']); 93 + 94 + expect(consoleLogSpy).toHaveBeenCalledWith('remote = (not set)'); 95 + }); 96 + }); 97 + 98 + describe('set command', () => { 99 + it('should set local config value', async () => { 100 + const mockGit = { 101 + checkIsRepo: vi.fn().mockResolvedValue(true), 102 + revparse: vi.fn().mockResolvedValue('/test/repo\n'), 103 + }; 104 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 105 + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); 106 + vi.mocked(fs.mkdir).mockResolvedValue(undefined); 107 + vi.mocked(fs.writeFile).mockResolvedValue(undefined); 108 + 109 + const config = createConfigCommand(); 110 + await config.parseAsync(['node', 'test', 'set', 'remote', 'origin']); 111 + 112 + expect(fs.writeFile).toHaveBeenCalledWith( 113 + '/test/repo/.tangledrc', 114 + `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`, 115 + 'utf-8' 116 + ); 117 + expect(consoleLogSpy).toHaveBeenCalledWith( 118 + expect.stringContaining('Set remote to "origin" in local config') 119 + ); 120 + }); 121 + 122 + it('should set global config value with --global flag', async () => { 123 + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); 124 + vi.mocked(fs.mkdir).mockResolvedValue(undefined); 125 + vi.mocked(fs.writeFile).mockResolvedValue(undefined); 126 + 127 + const config = createConfigCommand(); 128 + await config.parseAsync(['node', 'test', 'set', 'remote', 'origin', '--global']); 129 + 130 + expect(fs.writeFile).toHaveBeenCalledWith( 131 + expect.stringContaining('.tangledrc'), 132 + `${JSON.stringify({ remote: 'origin' }, null, 2)}\n`, 133 + 'utf-8' 134 + ); 135 + expect(consoleLogSpy).toHaveBeenCalledWith( 136 + expect.stringContaining('Set remote to "origin" in user config') 137 + ); 138 + }); 139 + 140 + it('should error when not in Git repo for local config', async () => { 141 + const mockGit = { 142 + checkIsRepo: vi.fn().mockResolvedValue(false), 143 + }; 144 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 145 + 146 + const config = createConfigCommand(); 147 + await expect(config.parseAsync(['node', 'test', 'set', 'remote', 'origin'])).rejects.toThrow( 148 + 'process.exit' 149 + ); 150 + 151 + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to set config')); 152 + }); 153 + 154 + it('should preserve existing config values', async () => { 155 + const mockGit = { 156 + checkIsRepo: vi.fn().mockResolvedValue(true), 157 + revparse: vi.fn().mockResolvedValue('/test/repo\n'), 158 + }; 159 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 160 + vi.mocked(fs.readFile).mockResolvedValue( 161 + JSON.stringify({ remote: 'origin', other: 'value' }) 162 + ); 163 + vi.mocked(fs.mkdir).mockResolvedValue(undefined); 164 + vi.mocked(fs.writeFile).mockResolvedValue(undefined); 165 + 166 + const config = createConfigCommand(); 167 + await config.parseAsync(['node', 'test', 'set', 'remote', 'upstream']); 168 + 169 + expect(fs.writeFile).toHaveBeenCalledWith( 170 + '/test/repo/.tangledrc', 171 + `${JSON.stringify({ remote: 'upstream', other: 'value' }, null, 2)}\n`, 172 + 'utf-8' 173 + ); 174 + }); 175 + }); 176 + 177 + describe('unset command', () => { 178 + it('should unset local config value', async () => { 179 + const mockGit = { 180 + checkIsRepo: vi.fn().mockResolvedValue(true), 181 + revparse: vi.fn().mockResolvedValue('/test/repo\n'), 182 + }; 183 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 184 + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' })); 185 + vi.mocked(fs.unlink).mockResolvedValue(undefined); 186 + 187 + const config = createConfigCommand(); 188 + await config.parseAsync(['node', 'test', 'unset', 'remote']); 189 + 190 + expect(fs.unlink).toHaveBeenCalledWith('/test/repo/.tangledrc'); 191 + expect(consoleLogSpy).toHaveBeenCalledWith( 192 + expect.stringContaining('Cleared remote from local config') 193 + ); 194 + }); 195 + 196 + it('should unset global config value with --global flag', async () => { 197 + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' })); 198 + vi.mocked(fs.unlink).mockResolvedValue(undefined); 199 + 200 + const config = createConfigCommand(); 201 + await config.parseAsync(['node', 'test', 'unset', 'remote', '--global']); 202 + 203 + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('.tangledrc')); 204 + expect(consoleLogSpy).toHaveBeenCalledWith( 205 + expect.stringContaining('Cleared remote from user config') 206 + ); 207 + }); 208 + 209 + it('should delete config file when last key is removed', async () => { 210 + const mockGit = { 211 + checkIsRepo: vi.fn().mockResolvedValue(true), 212 + revparse: vi.fn().mockResolvedValue('/test/repo\n'), 213 + }; 214 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 215 + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ remote: 'origin' })); 216 + vi.mocked(fs.unlink).mockResolvedValue(undefined); 217 + 218 + const config = createConfigCommand(); 219 + await config.parseAsync(['node', 'test', 'unset', 'remote']); 220 + 221 + expect(fs.unlink).toHaveBeenCalledWith('/test/repo/.tangledrc'); 222 + }); 223 + 224 + it('should preserve other config values when unsetting one key', async () => { 225 + const mockGit = { 226 + checkIsRepo: vi.fn().mockResolvedValue(true), 227 + revparse: vi.fn().mockResolvedValue('/test/repo\n'), 228 + }; 229 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 230 + vi.mocked(fs.readFile).mockResolvedValue( 231 + JSON.stringify({ remote: 'origin', other: 'value' }) 232 + ); 233 + vi.mocked(fs.writeFile).mockResolvedValue(undefined); 234 + 235 + const config = createConfigCommand(); 236 + await config.parseAsync(['node', 'test', 'unset', 'remote']); 237 + 238 + expect(fs.writeFile).toHaveBeenCalledWith( 239 + '/test/repo/.tangledrc', 240 + `${JSON.stringify({ other: 'value' }, null, 2)}\n`, 241 + 'utf-8' 242 + ); 243 + }); 244 + 245 + it('should handle unset when config does not exist', async () => { 246 + const mockGit = { 247 + checkIsRepo: vi.fn().mockResolvedValue(true), 248 + revparse: vi.fn().mockResolvedValue('/test/repo\n'), 249 + }; 250 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 251 + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); 252 + 253 + const config = createConfigCommand(); 254 + await config.parseAsync(['node', 'test', 'unset', 'remote']); 255 + 256 + expect(consoleLogSpy).toHaveBeenCalledWith( 257 + expect.stringContaining('Cleared remote from local config') 258 + ); 259 + }); 260 + }); 261 + });