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! :)
1import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
2import { homedir } from 'node:os';
3import { dirname, join } from 'node:path';
4import { Command } from 'commander';
5import { simpleGit } from 'simple-git';
6import { loadConfig, type TangledConfig } from '../lib/config.js';
7
8/**
9 * Get Git root directory
10 */
11async 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 */
28async 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 */
60async 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 */
101const 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 */
108export 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}