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 ssh-key features.

Defer importing ssh keys automatically as it's too large in scope right now.

+250 -11
+23 -11
TODO.md
··· 21 21 22 22 ## Git SSH Key Management 23 23 24 - - [ ] Implement `tangled ssh-key add <public-key-path>` command. 25 - - [ ] This command should upload the provided public SSH key to the user's tangled.org account via the API, similar to how `gh ssh-key add` works. If no path is provided, it should default to `~/.ssh/id_rsa.pub` or prompt the user for a path. 26 - - [ ] The CLI is not responsible for generating SSH keys or managing the local ssh-agent; users are expected to handle these steps externally. 27 - - [ ] Implement `tangled ssh-key verify` command. 28 - - [ ] This command should execute `ssh -T git@tangled.org`, parse the DID from its output, and then resolve that DID to a Bluesky handle, displaying the result to the user. 29 - - [ ] Ensure all Git operations leverage SSH keys for authentication, as `tangled.org` exclusively supports SSH for Git. 24 + - [x] Implement `tangled ssh-key verify` command. 25 + - [x] This command executes `ssh -T git@tangled.org`, parses the DID from its output, and displays it to the user. 26 + - [x] If the user is logged in with the CLI and their DID matches the SSH DID, their handle is also displayed. 30 27 31 28 ## Context Engine (Git Integration) 32 29 ··· 78 75 79 76 - [ ] This phase primarily involves local Git operations (pushing new commits) and using `tangled pr comment` for clarifications, which are covered by existing or planned commands. 80 77 78 + ## SSH Key Upload & Management (Phase 4) 79 + 80 + This phase adds CLI-based SSH key management for users who want to upload keys programmatically. 81 + 82 + - [ ] Implement `tangled ssh-key add <public-key-path>` command. 83 + - [ ] This command should upload the provided public SSH key to the user's tangled.org account via the API, similar to how `gh ssh-key add` works. If no path is provided, it should default to `~/.ssh/id_ed25519.pub` or prompt the user for a path. 84 + - [ ] Support reading keys from SSH agent via `ssh-add -L` for 1Password SSH agent users. 85 + - [ ] The CLI is not responsible for generating SSH keys or managing the local ssh-agent; users are expected to handle these steps externally. 86 + - [ ] Implement `tangled ssh-key list` command. 87 + - [ ] List all SSH keys stored in the user's PDS. 88 + - [ ] Display key type, name, creation date, and URI. 89 + 81 90 ## Output & LLM Integration 82 91 83 92 - [ ] Implement output formatting based on `is-interactive` check. ··· 88 97 89 98 ## Testing 90 99 91 - - [ ] Set up a testing framework (e.g., Jest, Vitest). 92 - - [ ] Write unit tests for core modules (Auth, Context Resolver, API client). 93 - - [ ] Write integration tests for CLI commands. 100 + - [x] Set up a testing framework (Vitest). 101 + - [x] Write unit tests for core modules (Auth, Session, API client, Validation, Prompts). 102 + - [x] Write integration tests for CLI commands (Auth, SSH key verify). 103 + - [ ] Add integration tests for remaining commands as they are implemented. 94 104 95 105 ## Documentation & Deployment 96 106 ··· 99 109 100 110 ## Outstanding Issues / Future Considerations (from README) 101 111 102 - - [ ] Secure cross-platform AT Proto session storage (OS keychain). 103 - - [ ] Git authentication management similar to GitHub CLI (SSH keys, 1Password integration). 112 + - [x] Secure cross-platform AT Proto session storage (OS keychain) - Implemented with @napi-rs/keyring. 113 + - [x] SSH key verification for Git authentication - Implemented `tangled ssh-key verify`. 114 + - [ ] SSH key upload management (See Phase 4 above). 115 + - [ ] 1Password SSH agent integration for key upload (See Phase 4 above). 104 116 - [ ] Define clear precedence order for settings resolution (local config, home folder, CLI flags). 105 117 - [ ] Consider adding extensions/plugins (Out of Scope for V1, but keep in mind).
+75
src/commands/ssh-key.ts
··· 1 + import { execSync } from 'node:child_process'; 2 + import { Command } from 'commander'; 3 + import { getCurrentSessionMetadata } from '../lib/session.js'; 4 + 5 + /** 6 + * Create the ssh-key command with subcommands for managing SSH keys 7 + */ 8 + export function createSshKeyCommand(): Command { 9 + const sshKey = new Command('ssh-key'); 10 + sshKey.description('Verify SSH key setup for Git authentication'); 11 + 12 + // Verify command 13 + sshKey 14 + .command('verify') 15 + .description('Verify SSH key authentication with git@tangled.org') 16 + .action(async () => { 17 + try { 18 + console.log('Testing SSH connection to git@tangled.org...\n'); 19 + 20 + // Execute ssh -T git@tangled.org to test authentication 21 + let output: string; 22 + try { 23 + output = execSync('ssh -T git@tangled.org', { 24 + encoding: 'utf-8', 25 + stdio: 'pipe', 26 + }); 27 + } catch (error) { 28 + // ssh -T returns non-zero exit code even on success 29 + // Capture stderr which contains the authentication message 30 + if (error instanceof Error && 'stderr' in error) { 31 + output = (error as { stderr: string }).stderr; 32 + } else { 33 + throw error; 34 + } 35 + } 36 + 37 + // Parse the DID from the output 38 + // Expected format: "Hi @did:plc:...! You've successfully authenticated." 39 + const didMatch = output.match(/@(did:plc:[a-z0-9]+)/i); 40 + 41 + if (!didMatch) { 42 + console.error('✗ SSH authentication failed'); 43 + console.error('Could not find authenticated DID in response'); 44 + console.error('\nPlease ensure you have:'); 45 + console.error('1. Generated an SSH key (ssh-keygen -t ed25519)'); 46 + console.error('2. Added your public key to https://tangled.org/settings/keys'); 47 + console.error('3. Your SSH agent is running (ssh-add -l)'); 48 + process.exit(1); 49 + } 50 + 51 + const did = didMatch[1]; 52 + console.log('✓ SSH authentication successful'); 53 + console.log(` Authenticated as: ${did}`); 54 + 55 + // Check if this matches the logged-in user 56 + const session = await getCurrentSessionMetadata(); 57 + if (session && session.did === did) { 58 + console.log(` Handle: @${session.handle}`); 59 + } 60 + 61 + console.log('\n✓ Your SSH setup is working correctly!'); 62 + } catch (error) { 63 + console.error( 64 + `\n✗ Failed to verify SSH setup: ${error instanceof Error ? error.message : 'Unknown error'}` 65 + ); 66 + console.error('\nPlease ensure you have:'); 67 + console.error('1. Generated an SSH key (ssh-keygen -t ed25519)'); 68 + console.error('2. Added your public key to https://tangled.org/settings/keys'); 69 + console.error('3. Your SSH agent is running (ssh-add -l)'); 70 + process.exit(1); 71 + } 72 + }); 73 + 74 + return sshKey; 75 + }
+2
src/index.ts
··· 4 4 import { fileURLToPath } from 'node:url'; 5 5 import { Command } from 'commander'; 6 6 import { createAuthCommand } from './commands/auth.js'; 7 + import { createSshKeyCommand } from './commands/ssh-key.js'; 7 8 8 9 // Get package.json for version 9 10 const __filename = fileURLToPath(import.meta.url); ··· 19 20 20 21 // Register commands 21 22 program.addCommand(createAuthCommand()); 23 + program.addCommand(createSshKeyCommand()); 22 24 23 25 program.parse(process.argv);
+150
tests/commands/ssh-key.test.ts
··· 1 + import { execSync } from 'node:child_process'; 2 + import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 + import { createSshKeyCommand } from '../../src/commands/ssh-key.js'; 4 + import * as sessionModule from '../../src/lib/session.js'; 5 + 6 + vi.mock('node:child_process'); 7 + vi.mock('../../src/lib/session.js'); 8 + 9 + describe('SSH Key Commands', () => { 10 + let consoleLogSpy: ReturnType<typeof vi.fn>; 11 + let consoleErrorSpy: ReturnType<typeof vi.fn>; 12 + let processExitSpy: ReturnType<typeof vi.fn>; 13 + 14 + beforeEach(() => { 15 + vi.clearAllMocks(); 16 + 17 + // Mock console methods 18 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 19 + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 20 + 21 + // Mock process.exit to throw to stop execution 22 + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { 23 + throw new Error(`process.exit(${code})`); 24 + }) as never; 25 + }); 26 + 27 + describe('verify command', () => { 28 + it('should parse DID from successful SSH response', async () => { 29 + // Mock successful SSH response with actual format from tangled.org 30 + const mockSshOutput = 31 + "Hi @did:plc:b2mcbcamkwyznc5fkplwlxbf! You've successfully authenticated.\n"; 32 + 33 + vi.mocked(execSync).mockImplementation(() => { 34 + // ssh -T returns non-zero exit code even on success, throw with stderr 35 + const error = new Error('SSH command') as Error & { stderr: string }; 36 + error.stderr = mockSshOutput; 37 + throw error; 38 + }); 39 + 40 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); 41 + 42 + const sshKey = createSshKeyCommand(); 43 + await sshKey.parseAsync(['node', 'test', 'verify']); 44 + 45 + expect(consoleLogSpy).toHaveBeenCalledWith( 46 + expect.stringContaining('SSH authentication successful') 47 + ); 48 + expect(consoleLogSpy).toHaveBeenCalledWith( 49 + expect.stringContaining('did:plc:b2mcbcamkwyznc5fkplwlxbf') 50 + ); 51 + expect(consoleLogSpy).toHaveBeenCalledWith( 52 + expect.stringContaining('Your SSH setup is working correctly') 53 + ); 54 + }); 55 + 56 + it('should show handle when logged in user matches SSH DID', async () => { 57 + const mockDid = 'did:plc:b2mcbcamkwyznc5fkplwlxbf'; 58 + const mockSshOutput = `Hi @${mockDid}! You've successfully authenticated.\n`; 59 + 60 + vi.mocked(execSync).mockImplementation(() => { 61 + const error = new Error('SSH command') as Error & { stderr: string }; 62 + error.stderr = mockSshOutput; 63 + throw error; 64 + }); 65 + 66 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue({ 67 + handle: 'user.bsky.social', 68 + did: mockDid, 69 + pds: 'https://bsky.social', 70 + lastUsed: new Date().toISOString(), 71 + }); 72 + 73 + const sshKey = createSshKeyCommand(); 74 + await sshKey.parseAsync(['node', 'test', 'verify']); 75 + 76 + expect(consoleLogSpy).toHaveBeenCalledWith( 77 + expect.stringContaining('did:plc:b2mcbcamkwyznc5fkplwlxbf') 78 + ); 79 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('@user.bsky.social')); 80 + }); 81 + 82 + it('should not show handle when logged in user does not match SSH DID', async () => { 83 + const mockSshOutput = 84 + "Hi @did:plc:b2mcbcamkwyznc5fkplwlxbf! You've successfully authenticated.\n"; 85 + 86 + vi.mocked(execSync).mockImplementation(() => { 87 + const error = new Error('SSH command') as Error & { stderr: string }; 88 + error.stderr = mockSshOutput; 89 + throw error; 90 + }); 91 + 92 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue({ 93 + handle: 'otheruser.bsky.social', 94 + did: 'did:plc:differentuser', 95 + pds: 'https://bsky.social', 96 + lastUsed: new Date().toISOString(), 97 + }); 98 + 99 + const sshKey = createSshKeyCommand(); 100 + await sshKey.parseAsync(['node', 'test', 'verify']); 101 + 102 + expect(consoleLogSpy).toHaveBeenCalledWith( 103 + expect.stringContaining('did:plc:b2mcbcamkwyznc5fkplwlxbf') 104 + ); 105 + expect(consoleLogSpy).not.toHaveBeenCalledWith( 106 + expect.stringContaining('@otheruser.bsky.social') 107 + ); 108 + }); 109 + 110 + it('should handle SSH authentication failure', async () => { 111 + const mockSshOutput = 'Permission denied (publickey).\n'; 112 + 113 + vi.mocked(execSync).mockImplementation(() => { 114 + const error = new Error('SSH command') as Error & { stderr: string }; 115 + error.stderr = mockSshOutput; 116 + throw error; 117 + }); 118 + 119 + const sshKey = createSshKeyCommand(); 120 + await expect(sshKey.parseAsync(['node', 'test', 'verify'])).rejects.toThrow('process.exit'); 121 + 122 + expect(consoleErrorSpy).toHaveBeenCalledWith( 123 + expect.stringContaining('SSH authentication failed') 124 + ); 125 + expect(consoleErrorSpy).toHaveBeenCalledWith( 126 + expect.stringContaining('Could not find authenticated DID') 127 + ); 128 + expect(processExitSpy).toHaveBeenCalledWith(1); 129 + }); 130 + 131 + it('should provide helpful error message on failure', async () => { 132 + const mockSshOutput = 'Connection refused'; 133 + 134 + vi.mocked(execSync).mockImplementation(() => { 135 + const error = new Error('SSH command') as Error & { stderr: string }; 136 + error.stderr = mockSshOutput; 137 + throw error; 138 + }); 139 + 140 + const sshKey = createSshKeyCommand(); 141 + await expect(sshKey.parseAsync(['node', 'test', 'verify'])).rejects.toThrow('process.exit'); 142 + 143 + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Generated an SSH key')); 144 + expect(consoleErrorSpy).toHaveBeenCalledWith( 145 + expect.stringContaining('tangled.org/settings/keys') 146 + ); 147 + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('SSH agent is running')); 148 + }); 149 + }); 150 + });