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.

Fix repository AT-URI to use record rkey instead of name

Issue records were not appearing on tangled.org because the repo field
used an incorrect AT-URI format with the repository name as the rkey
instead of the actual record key.

Changes:
- Update buildRepoAtUri() to query PDS for sh.tangled.repo records
- Find matching repository by name field in record value
- Return record's URI which contains the correct rkey
- Add comprehensive tests for new query-based resolution

Before: at://did:plc:xxx/sh.tangled.repo/tangled-cli
After: at://did:plc:xxx/sh.tangled.repo/3mef23waqwq22

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

+155 -19
+29 -12
src/utils/at-uri.ts
··· 64 64 * @param ownerDidOrHandle - DID (e.g., "did:plc:abc") or handle (e.g., "mark.bsky.social") 65 65 * @param repoName - Repository name 66 66 * @param client - Authenticated API client 67 - * @returns AT-URI string (e.g., "at://did:plc:abc/sh.tangled.repo/repoName") 67 + * @returns AT-URI string (e.g., "at://did:plc:abc/sh.tangled.repo/3mef23waqwq22") 68 + * @throws Error if repository not found 68 69 */ 69 70 export async function buildRepoAtUri( 70 71 ownerDidOrHandle: string, 71 72 repoName: string, 72 73 client: TangledApiClient 73 74 ): Promise<string> { 74 - // Check if owner is already a DID 75 + // Resolve owner to DID 75 76 const isDid = ownerDidOrHandle.startsWith('did:'); 77 + const did = isDid ? ownerDidOrHandle : await resolveHandleToDid(ownerDidOrHandle, client); 76 78 77 - let did: string; 78 - if (isDid) { 79 - did = ownerDidOrHandle; 80 - } else { 81 - // Resolve handle to DID 82 - did = await resolveHandleToDid(ownerDidOrHandle, client); 83 - } 79 + try { 80 + // Query for sh.tangled.repo records 81 + const response = await client.getAgent().com.atproto.repo.listRecords({ 82 + repo: did, 83 + collection: 'sh.tangled.repo', 84 + limit: 100, // Reasonable limit for most users 85 + }); 86 + 87 + // Find the record matching the repo name 88 + const repoRecord = response.data.records.find((record) => { 89 + const recordData = record.value as { name?: string }; 90 + return recordData.name === repoName; 91 + }); 92 + 93 + if (!repoRecord) { 94 + throw new Error(`Repository '${repoName}' not found for ${ownerDidOrHandle}`); 95 + } 84 96 85 - // Construct AT-URI for repository 86 - // Format: at://{did}/sh.tangled.repo/{repoName} 87 - return `at://${did}/sh.tangled.repo/${repoName}`; 97 + // Return the record's URI (which includes the correct rkey) 98 + return repoRecord.uri; 99 + } catch (error) { 100 + if (error instanceof Error) { 101 + throw new Error(`Failed to resolve repository AT-URI: ${error.message}`); 102 + } 103 + throw new Error('Failed to resolve repository AT-URI: Unknown error'); 104 + } 88 105 }
+126 -7
tests/utils/at-uri.test.ts
··· 163 163 mockClient = createMockClient(); 164 164 }); 165 165 166 - it('should build AT-URI from DID', async () => { 166 + it('should query PDS and use repo record rkey', async () => { 167 + const mockListRecords = vi.fn().mockResolvedValue({ 168 + data: { 169 + records: [ 170 + { 171 + uri: 'at://did:plc:abc123/sh.tangled.repo/3mef23waqwq22', 172 + value: { name: 'my-repo', description: 'Test repo' }, 173 + }, 174 + ], 175 + }, 176 + }); 177 + 178 + vi.mocked(mockClient.getAgent).mockReturnValue({ 179 + com: { 180 + atproto: { 181 + repo: { 182 + listRecords: mockListRecords, 183 + }, 184 + }, 185 + }, 186 + } as never); 187 + 167 188 const result = await buildRepoAtUri('did:plc:abc123', 'my-repo', mockClient); 168 189 169 - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); 190 + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/3mef23waqwq22'); 191 + expect(mockListRecords).toHaveBeenCalledWith({ 192 + repo: 'did:plc:abc123', 193 + collection: 'sh.tangled.repo', 194 + limit: 100, 195 + }); 170 196 }); 171 197 172 - it('should build AT-URI from handle', async () => { 198 + it('should resolve handle then query for repo record', async () => { 173 199 const mockResolve = vi.fn().mockResolvedValue({ 174 200 data: { did: 'did:plc:abc123' }, 175 201 }); 176 202 203 + const mockListRecords = vi.fn().mockResolvedValue({ 204 + data: { 205 + records: [ 206 + { 207 + uri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 208 + value: { name: 'my-repo' }, 209 + }, 210 + ], 211 + }, 212 + }); 213 + 177 214 vi.mocked(mockClient.getAgent).mockReturnValue({ 178 215 com: { 179 216 atproto: { 180 217 identity: { 181 218 resolveHandle: mockResolve, 182 219 }, 220 + repo: { 221 + listRecords: mockListRecords, 222 + }, 183 223 }, 184 224 }, 185 225 } as never); 186 226 187 227 const result = await buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient); 188 228 189 - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/my-repo'); 229 + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/xyz789'); 190 230 expect(mockResolve).toHaveBeenCalledWith({ handle: 'mark.bsky.social' }); 231 + expect(mockListRecords).toHaveBeenCalledWith({ 232 + repo: 'did:plc:abc123', 233 + collection: 'sh.tangled.repo', 234 + limit: 100, 235 + }); 191 236 }); 192 237 193 - it('should handle repository names with special characters', async () => { 194 - const result = await buildRepoAtUri('did:plc:abc123', 'repo-name_123', mockClient); 238 + it('should find correct repo among multiple records', async () => { 239 + const mockListRecords = vi.fn().mockResolvedValue({ 240 + data: { 241 + records: [ 242 + { 243 + uri: 'at://did:plc:abc123/sh.tangled.repo/aaa111', 244 + value: { name: 'other-repo' }, 245 + }, 246 + { 247 + uri: 'at://did:plc:abc123/sh.tangled.repo/bbb222', 248 + value: { name: 'target-repo' }, 249 + }, 250 + { 251 + uri: 'at://did:plc:abc123/sh.tangled.repo/ccc333', 252 + value: { name: 'another-repo' }, 253 + }, 254 + ], 255 + }, 256 + }); 257 + 258 + vi.mocked(mockClient.getAgent).mockReturnValue({ 259 + com: { 260 + atproto: { 261 + repo: { 262 + listRecords: mockListRecords, 263 + }, 264 + }, 265 + }, 266 + } as never); 267 + 268 + const result = await buildRepoAtUri('did:plc:abc123', 'target-repo', mockClient); 195 269 196 - expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/repo-name_123'); 270 + expect(result).toBe('at://did:plc:abc123/sh.tangled.repo/bbb222'); 271 + }); 272 + 273 + it('should throw error when repository not found', async () => { 274 + const mockListRecords = vi.fn().mockResolvedValue({ 275 + data: { 276 + records: [ 277 + { 278 + uri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 279 + value: { name: 'different-repo' }, 280 + }, 281 + ], 282 + }, 283 + }); 284 + 285 + vi.mocked(mockClient.getAgent).mockReturnValue({ 286 + com: { 287 + atproto: { 288 + repo: { 289 + listRecords: mockListRecords, 290 + }, 291 + }, 292 + }, 293 + } as never); 294 + 295 + await expect(buildRepoAtUri('did:plc:abc123', 'nonexistent-repo', mockClient)).rejects.toThrow( 296 + "Repository 'nonexistent-repo' not found for did:plc:abc123" 297 + ); 197 298 }); 198 299 199 300 it('should throw error when handle resolution fails', async () => { ··· 211 312 212 313 await expect(buildRepoAtUri('mark.bsky.social', 'my-repo', mockClient)).rejects.toThrow( 213 314 "Failed to resolve handle 'mark.bsky.social': Resolution failed" 315 + ); 316 + }); 317 + 318 + it('should throw error when listRecords fails', async () => { 319 + const mockListRecords = vi.fn().mockRejectedValue(new Error('API error')); 320 + 321 + vi.mocked(mockClient.getAgent).mockReturnValue({ 322 + com: { 323 + atproto: { 324 + repo: { 325 + listRecords: mockListRecords, 326 + }, 327 + }, 328 + }, 329 + } as never); 330 + 331 + await expect(buildRepoAtUri('did:plc:abc123', 'my-repo', mockClient)).rejects.toThrow( 332 + 'Failed to resolve repository AT-URI: API error' 214 333 ); 215 334 }); 216 335 });