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.

Implement repository context resolution from Git remotes

Add context resolver to automatically detect tangled.org repositories:
- getCurrentRepoContext(): Get repository context from current directory
- getTangledRemotes(): Find all tangled.org remotes
- promptForRemote(): Interactive selection for multiple remotes
- Smart selection logic: prefer "origin", use config, or prompt
- Offers to save selection to config for future use

This enables commands to automatically determine which repository
they're operating on without requiring explicit flags.

Includes comprehensive test coverage (19 tests) for all resolution
scenarios including config handling and user prompts.

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

+593
+165
src/lib/context.ts
··· 1 + /** 2 + * Repository context resolution for Tangled CLI 3 + * Automatically infers repository context from Git remotes 4 + */ 5 + 6 + import { simpleGit } from 'simple-git'; 7 + import { isTangledRemote, parseTangledRemote } from '../utils/git.js'; 8 + import { promptForRemoteSelection, promptToSaveRemote } from '../utils/prompts.js'; 9 + import { getConfiguredRemote, setLocalRemote } from './config.js'; 10 + 11 + export interface RepositoryContext { 12 + owner: string; // Owner identifier - DID (e.g., "did:plc:...") or handle (e.g., "markbennett.ca") 13 + ownerType: 'did' | 'handle'; // Type of owner identifier 14 + name: string; // Repository name (e.g., "tangled-cli") 15 + remoteName: string; // Git remote name (e.g., "origin") 16 + remoteUrl: string; // Full remote URL 17 + protocol: 'ssh' | 'https'; // Protocol used by remote 18 + } 19 + 20 + /** 21 + * Get all tangled.org remotes from the current Git repository 22 + * 23 + * @param cwd - Current working directory 24 + * @returns Array of repository contexts 25 + */ 26 + export async function getTangledRemotes(cwd: string = process.cwd()): Promise<RepositoryContext[]> { 27 + try { 28 + const git = simpleGit(cwd); 29 + 30 + // Check if in a Git repository 31 + const isRepo = await git.checkIsRepo(); 32 + if (!isRepo) { 33 + return []; 34 + } 35 + 36 + // Get all remotes with URLs 37 + const remotes = await git.getRemotes(true); 38 + 39 + // Filter and parse tangled.org remotes 40 + const tangledRemotes: RepositoryContext[] = []; 41 + 42 + for (const remote of remotes) { 43 + if (!remote.refs.fetch || !isTangledRemote(remote.refs.fetch)) { 44 + continue; 45 + } 46 + 47 + const parsed = parseTangledRemote(remote.refs.fetch); 48 + if (!parsed) { 49 + console.warn(`Warning: Invalid tangled.org remote URL: ${remote.refs.fetch}`); 50 + continue; 51 + } 52 + 53 + tangledRemotes.push({ 54 + owner: parsed.owner, 55 + ownerType: parsed.ownerType, 56 + name: parsed.name, 57 + remoteName: remote.name, 58 + remoteUrl: remote.refs.fetch, 59 + protocol: parsed.protocol, 60 + }); 61 + } 62 + 63 + return tangledRemotes; 64 + } catch (error) { 65 + // Git errors - return empty array 66 + return []; 67 + } 68 + } 69 + 70 + /** 71 + * Prompt user to select a remote when multiple tangled remotes exist 72 + * 73 + * @param remotes - Array of repository contexts 74 + * @returns Selected repository context 75 + */ 76 + export async function promptForRemote(remotes: RepositoryContext[]): Promise<RepositoryContext> { 77 + if (remotes.length === 0) { 78 + throw new Error('No remotes available to select from'); 79 + } 80 + 81 + if (remotes.length === 1) { 82 + return remotes[0]; 83 + } 84 + 85 + // Convert to format expected by prompt 86 + const remoteChoices = remotes.map((r) => ({ 87 + name: r.remoteName, 88 + url: r.remoteUrl, 89 + })); 90 + 91 + const selectedName = await promptForRemoteSelection(remoteChoices); 92 + 93 + const selected = remotes.find((r) => r.remoteName === selectedName); 94 + if (!selected) { 95 + throw new Error(`Selected remote "${selectedName}" not found`); 96 + } 97 + 98 + return selected; 99 + } 100 + 101 + /** 102 + * Get repository context from the current working directory 103 + * Looks for Git remotes pointing to tangled.org 104 + * 105 + * @param cwd - Current working directory (defaults to process.cwd()) 106 + * @returns Repository context or null if not in a tangled repo 107 + */ 108 + export async function getCurrentRepoContext( 109 + cwd: string = process.cwd() 110 + ): Promise<RepositoryContext | null> { 111 + // Get all tangled remotes 112 + const remotes = await getTangledRemotes(cwd); 113 + 114 + // No tangled remotes found 115 + if (remotes.length === 0) { 116 + return null; 117 + } 118 + 119 + // Single remote - use it 120 + if (remotes.length === 1) { 121 + return remotes[0]; 122 + } 123 + 124 + // Multiple remotes - check config first 125 + const configuredRemote = await getConfiguredRemote(cwd); 126 + 127 + if (configuredRemote) { 128 + // Check if configured remote exists and is a tangled remote 129 + const matchingRemote = remotes.find((r) => r.remoteName === configuredRemote); 130 + 131 + if (matchingRemote) { 132 + return matchingRemote; 133 + } 134 + 135 + // Configured remote doesn't exist or isn't a tangled remote 136 + console.warn( 137 + `Warning: Configured remote "${configuredRemote}" not found or is not a tangled.org remote. Continuing with heuristics.` 138 + ); 139 + } 140 + 141 + // Check for "origin" remote 142 + const originRemote = remotes.find((r) => r.remoteName === 'origin'); 143 + if (originRemote) { 144 + return originRemote; 145 + } 146 + 147 + // Prompt user to select 148 + const selected = await promptForRemote(remotes); 149 + 150 + // Ask if user wants to save selection 151 + const shouldSave = await promptToSaveRemote(); 152 + if (shouldSave) { 153 + try { 154 + await setLocalRemote(selected.remoteName, cwd); 155 + console.log(`✓ Saved remote "${selected.remoteName}" to local config\n`); 156 + } catch (error) { 157 + console.warn( 158 + `Warning: Failed to save config: ${error instanceof Error ? error.message : 'Unknown error'}` 159 + ); 160 + // Don't block command execution if config save fails 161 + } 162 + } 163 + 164 + return selected; 165 + }
+428
tests/lib/context.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import type { RepositoryContext } from '../../src/lib/context.js'; 3 + import { 4 + getCurrentRepoContext, 5 + getTangledRemotes, 6 + promptForRemote, 7 + } from '../../src/lib/context.js'; 8 + 9 + // Mock modules 10 + vi.mock('simple-git'); 11 + vi.mock('../../src/lib/config.js'); 12 + vi.mock('../../src/utils/prompts.js'); 13 + 14 + // Import mocked modules 15 + import { simpleGit } from 'simple-git'; 16 + import * as configModule from '../../src/lib/config.js'; 17 + import * as promptsModule from '../../src/utils/prompts.js'; 18 + 19 + describe('Context Resolution', () => { 20 + beforeEach(() => { 21 + vi.clearAllMocks(); 22 + }); 23 + 24 + describe('getTangledRemotes', () => { 25 + it('should return empty array when not in a Git repository', async () => { 26 + const mockGit = { 27 + checkIsRepo: vi.fn().mockResolvedValue(false), 28 + }; 29 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 30 + 31 + const remotes = await getTangledRemotes(); 32 + 33 + expect(remotes).toEqual([]); 34 + }); 35 + 36 + it('should return empty array when no tangled remotes exist', async () => { 37 + const mockGit = { 38 + checkIsRepo: vi.fn().mockResolvedValue(true), 39 + getRemotes: vi 40 + .fn() 41 + .mockResolvedValue([{ name: 'origin', refs: { fetch: 'git@github.com:user/repo.git' } }]), 42 + }; 43 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 44 + 45 + const remotes = await getTangledRemotes(); 46 + 47 + expect(remotes).toEqual([]); 48 + }); 49 + 50 + it('should parse SSH tangled remote', async () => { 51 + const mockGit = { 52 + checkIsRepo: vi.fn().mockResolvedValue(true), 53 + getRemotes: vi.fn().mockResolvedValue([ 54 + { 55 + name: 'origin', 56 + refs: { fetch: 'git@tangled.org:did:plc:b2mcbcamkwyznc5fkplwlxbf/tangled-cli.git' }, 57 + }, 58 + ]), 59 + }; 60 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 61 + 62 + const remotes = await getTangledRemotes(); 63 + 64 + expect(remotes).toEqual([ 65 + { 66 + owner: 'did:plc:b2mcbcamkwyznc5fkplwlxbf', 67 + ownerType: 'did', 68 + name: 'tangled-cli', 69 + remoteName: 'origin', 70 + remoteUrl: 'git@tangled.org:did:plc:b2mcbcamkwyznc5fkplwlxbf/tangled-cli.git', 71 + protocol: 'ssh', 72 + }, 73 + ]); 74 + }); 75 + 76 + it('should parse HTTPS tangled remote', async () => { 77 + const mockGit = { 78 + checkIsRepo: vi.fn().mockResolvedValue(true), 79 + getRemotes: vi.fn().mockResolvedValue([ 80 + { 81 + name: 'origin', 82 + refs: { fetch: 'https://tangled.org/markbennett.ca/tangled-cli' }, 83 + }, 84 + ]), 85 + }; 86 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 87 + 88 + const remotes = await getTangledRemotes(); 89 + 90 + expect(remotes).toEqual([ 91 + { 92 + owner: 'markbennett.ca', 93 + ownerType: 'handle', 94 + name: 'tangled-cli', 95 + remoteName: 'origin', 96 + remoteUrl: 'https://tangled.org/markbennett.ca/tangled-cli', 97 + protocol: 'https', 98 + }, 99 + ]); 100 + }); 101 + 102 + it('should parse multiple tangled remotes', async () => { 103 + const mockGit = { 104 + checkIsRepo: vi.fn().mockResolvedValue(true), 105 + getRemotes: vi.fn().mockResolvedValue([ 106 + { 107 + name: 'origin', 108 + refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, 109 + }, 110 + { 111 + name: 'upstream', 112 + refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, 113 + }, 114 + ]), 115 + }; 116 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 117 + 118 + const remotes = await getTangledRemotes(); 119 + 120 + expect(remotes).toHaveLength(2); 121 + expect(remotes[0].remoteName).toBe('origin'); 122 + expect(remotes[1].remoteName).toBe('upstream'); 123 + }); 124 + 125 + it('should skip invalid tangled remotes with warning', async () => { 126 + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 127 + 128 + const mockGit = { 129 + checkIsRepo: vi.fn().mockResolvedValue(true), 130 + getRemotes: vi.fn().mockResolvedValue([ 131 + { 132 + name: 'invalid', 133 + refs: { fetch: 'git@tangled.org:invalid' }, // Missing repo name 134 + }, 135 + { 136 + name: 'valid', 137 + refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, 138 + }, 139 + ]), 140 + }; 141 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 142 + 143 + const remotes = await getTangledRemotes(); 144 + 145 + expect(remotes).toHaveLength(1); 146 + expect(remotes[0].remoteName).toBe('valid'); 147 + expect(consoleWarnSpy).toHaveBeenCalledWith( 148 + expect.stringContaining('Invalid tangled.org remote URL') 149 + ); 150 + 151 + consoleWarnSpy.mockRestore(); 152 + }); 153 + 154 + it('should handle Git errors gracefully', async () => { 155 + const mockGit = { 156 + checkIsRepo: vi.fn().mockRejectedValue(new Error('Git error')), 157 + }; 158 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 159 + 160 + const remotes = await getTangledRemotes(); 161 + 162 + expect(remotes).toEqual([]); 163 + }); 164 + }); 165 + 166 + describe('promptForRemote', () => { 167 + it('should return single remote without prompting', async () => { 168 + const remote: RepositoryContext = { 169 + owner: 'did:plc:abc123', 170 + ownerType: 'did', 171 + name: 'repo', 172 + remoteName: 'origin', 173 + remoteUrl: 'git@tangled.org:did:plc:abc123/repo.git', 174 + protocol: 'ssh', 175 + }; 176 + 177 + const result = await promptForRemote([remote]); 178 + 179 + expect(result).toBe(remote); 180 + expect(promptsModule.promptForRemoteSelection).not.toHaveBeenCalled(); 181 + }); 182 + 183 + it('should prompt when multiple remotes available', async () => { 184 + const remotes: RepositoryContext[] = [ 185 + { 186 + owner: 'did:plc:abc123', 187 + ownerType: 'did', 188 + name: 'repo', 189 + remoteName: 'origin', 190 + remoteUrl: 'git@tangled.org:did:plc:abc123/repo.git', 191 + protocol: 'ssh', 192 + }, 193 + { 194 + owner: 'did:plc:xyz789', 195 + ownerType: 'did', 196 + name: 'repo', 197 + remoteName: 'upstream', 198 + remoteUrl: 'git@tangled.org:did:plc:xyz789/repo.git', 199 + protocol: 'ssh', 200 + }, 201 + ]; 202 + 203 + vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('upstream'); 204 + 205 + const result = await promptForRemote(remotes); 206 + 207 + expect(result.remoteName).toBe('upstream'); 208 + expect(promptsModule.promptForRemoteSelection).toHaveBeenCalledWith([ 209 + { name: 'origin', url: 'git@tangled.org:did:plc:abc123/repo.git' }, 210 + { name: 'upstream', url: 'git@tangled.org:did:plc:xyz789/repo.git' }, 211 + ]); 212 + }); 213 + 214 + it('should throw error when no remotes provided', async () => { 215 + await expect(promptForRemote([])).rejects.toThrow('No remotes available to select from'); 216 + }); 217 + }); 218 + 219 + describe('getCurrentRepoContext', () => { 220 + it('should return null when not in a Git repository', async () => { 221 + const mockGit = { 222 + checkIsRepo: vi.fn().mockResolvedValue(false), 223 + }; 224 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 225 + 226 + const context = await getCurrentRepoContext(); 227 + 228 + expect(context).toBeNull(); 229 + }); 230 + 231 + it('should return null when no tangled remotes exist', async () => { 232 + const mockGit = { 233 + checkIsRepo: vi.fn().mockResolvedValue(true), 234 + getRemotes: vi 235 + .fn() 236 + .mockResolvedValue([{ name: 'origin', refs: { fetch: 'git@github.com:user/repo.git' } }]), 237 + }; 238 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 239 + 240 + const context = await getCurrentRepoContext(); 241 + 242 + expect(context).toBeNull(); 243 + }); 244 + 245 + it('should return single tangled remote', async () => { 246 + const mockGit = { 247 + checkIsRepo: vi.fn().mockResolvedValue(true), 248 + getRemotes: vi.fn().mockResolvedValue([ 249 + { 250 + name: 'origin', 251 + refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, 252 + }, 253 + ]), 254 + }; 255 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 256 + 257 + const context = await getCurrentRepoContext(); 258 + 259 + expect(context).toEqual({ 260 + owner: 'did:plc:abc123', 261 + ownerType: 'did', 262 + name: 'repo', 263 + remoteName: 'origin', 264 + remoteUrl: 'git@tangled.org:did:plc:abc123/repo.git', 265 + protocol: 'ssh', 266 + }); 267 + }); 268 + 269 + it('should use configured remote when multiple remotes exist', async () => { 270 + const mockGit = { 271 + checkIsRepo: vi.fn().mockResolvedValue(true), 272 + getRemotes: vi.fn().mockResolvedValue([ 273 + { 274 + name: 'origin', 275 + refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, 276 + }, 277 + { 278 + name: 'upstream', 279 + refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, 280 + }, 281 + ]), 282 + }; 283 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 284 + vi.mocked(configModule.getConfiguredRemote).mockResolvedValue('upstream'); 285 + 286 + const context = await getCurrentRepoContext(); 287 + 288 + expect(context?.remoteName).toBe('upstream'); 289 + }); 290 + 291 + it('should fall back to origin when config points to non-existent remote', async () => { 292 + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 293 + 294 + const mockGit = { 295 + checkIsRepo: vi.fn().mockResolvedValue(true), 296 + getRemotes: vi.fn().mockResolvedValue([ 297 + { 298 + name: 'origin', 299 + refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, 300 + }, 301 + { 302 + name: 'upstream', 303 + refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, 304 + }, 305 + ]), 306 + }; 307 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 308 + vi.mocked(configModule.getConfiguredRemote).mockResolvedValue('nonexistent'); 309 + 310 + const context = await getCurrentRepoContext(); 311 + 312 + expect(context?.remoteName).toBe('origin'); 313 + expect(consoleWarnSpy).toHaveBeenCalledWith( 314 + expect.stringContaining('Configured remote "nonexistent" not found') 315 + ); 316 + 317 + consoleWarnSpy.mockRestore(); 318 + }); 319 + 320 + it('should prefer origin remote when no config set', async () => { 321 + const mockGit = { 322 + checkIsRepo: vi.fn().mockResolvedValue(true), 323 + getRemotes: vi.fn().mockResolvedValue([ 324 + { 325 + name: 'upstream', 326 + refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, 327 + }, 328 + { 329 + name: 'origin', 330 + refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, 331 + }, 332 + ]), 333 + }; 334 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 335 + vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null); 336 + 337 + const context = await getCurrentRepoContext(); 338 + 339 + expect(context?.remoteName).toBe('origin'); 340 + }); 341 + 342 + it('should prompt when no origin and no config', async () => { 343 + const mockGit = { 344 + checkIsRepo: vi.fn().mockResolvedValue(true), 345 + getRemotes: vi.fn().mockResolvedValue([ 346 + { 347 + name: 'upstream', 348 + refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, 349 + }, 350 + { 351 + name: 'fork', 352 + refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, 353 + }, 354 + ]), 355 + }; 356 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 357 + vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null); 358 + vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('fork'); 359 + vi.mocked(promptsModule.promptToSaveRemote).mockResolvedValue(false); 360 + 361 + const context = await getCurrentRepoContext(); 362 + 363 + expect(context?.remoteName).toBe('fork'); 364 + expect(promptsModule.promptForRemoteSelection).toHaveBeenCalled(); 365 + }); 366 + 367 + it('should save config when user confirms', async () => { 368 + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 369 + 370 + const mockGit = { 371 + checkIsRepo: vi.fn().mockResolvedValue(true), 372 + getRemotes: vi.fn().mockResolvedValue([ 373 + { 374 + name: 'upstream', 375 + refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, 376 + }, 377 + { 378 + name: 'fork', 379 + refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, 380 + }, 381 + ]), 382 + }; 383 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 384 + vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null); 385 + vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('fork'); 386 + vi.mocked(promptsModule.promptToSaveRemote).mockResolvedValue(true); 387 + vi.mocked(configModule.setLocalRemote).mockResolvedValue(undefined); 388 + 389 + const context = await getCurrentRepoContext(); 390 + 391 + expect(context?.remoteName).toBe('fork'); 392 + expect(configModule.setLocalRemote).toHaveBeenCalledWith('fork', process.cwd()); 393 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Saved remote "fork"')); 394 + 395 + consoleLogSpy.mockRestore(); 396 + }); 397 + 398 + it('should continue even if saving config fails', async () => { 399 + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 400 + 401 + const mockGit = { 402 + checkIsRepo: vi.fn().mockResolvedValue(true), 403 + getRemotes: vi.fn().mockResolvedValue([ 404 + { 405 + name: 'upstream', 406 + refs: { fetch: 'git@tangled.org:did:plc:abc123/repo.git' }, 407 + }, 408 + { 409 + name: 'fork', 410 + refs: { fetch: 'git@tangled.org:did:plc:xyz789/repo.git' }, 411 + }, 412 + ]), 413 + }; 414 + vi.mocked(simpleGit).mockReturnValue(mockGit as never); 415 + vi.mocked(configModule.getConfiguredRemote).mockResolvedValue(null); 416 + vi.mocked(promptsModule.promptForRemoteSelection).mockResolvedValue('upstream'); 417 + vi.mocked(promptsModule.promptToSaveRemote).mockResolvedValue(true); 418 + vi.mocked(configModule.setLocalRemote).mockRejectedValue(new Error('Write failed')); 419 + 420 + const context = await getCurrentRepoContext(); 421 + 422 + expect(context?.remoteName).toBe('upstream'); 423 + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to save config')); 424 + 425 + consoleWarnSpy.mockRestore(); 426 + }); 427 + }); 428 + });