Skip to content

feat: add fuzzy reference resolution#19

Merged
gnapse merged 2 commits intomainfrom
feat/fuzzy-refs
Jan 25, 2026
Merged

feat: add fuzzy reference resolution#19
gnapse merged 2 commits intomainfrom
feat/fuzzy-refs

Conversation

@gnapse
Copy link
Copy Markdown
Contributor

@gnapse gnapse commented Jan 25, 2026

Summary

  • Add src/lib/refs.ts module with functions to resolve document and collection references by:
    • Exact ID (UUID or short alphanumeric format)
    • URL slug extraction (e.g., "my-doc-abc123" -> "abc123")
    • Exact name match (case-insensitive)
    • Partial name match (if unique match found)
    • Helpful error messages with suggestions for ambiguous matches
  • Update document commands to use fuzzy resolvers
  • Update collection commands to use fuzzy resolvers
  • Add comprehensive tests in src/lib/refs.test.ts

Closes #10

Test plan

  • Run npm run type-check - passes
  • Run npm test - all 50 tests pass
  • Verify document commands work with name references
  • Verify collection commands work with name references

🤖 Generated with Claude Code

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a fuzzy reference-resolution layer so users can refer to documents/collections by ID, URL slug, exact name, or unique partial name (with clearer errors for ambiguity), and wires it into the CLI commands.

Changes:

  • Added src/lib/refs.ts with generic fuzzy resolver plus document/collection helpers.
  • Updated document/collection commands to accept <ref> (ID/URL/name) and resolve to IDs before API calls.
  • Added unit tests for the new resolver logic in src/lib/refs.test.ts.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.

File Description
src/lib/refs.ts Implements fuzzy reference resolution and error messaging for documents/collections.
src/lib/refs.test.ts Adds Vitest coverage for ID/slug/name/partial resolution behavior.
src/commands/document.ts Switches document commands to use fuzzy resolvers (document + collection refs).
src/commands/collection.ts Switches collection commands to use fuzzy resolvers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/refs.ts Outdated
Comment on lines +167 to +171
async () => {
const { data } = await apiRequest<Collection[]>("collections.list", {
limit: 100,
});
return data;
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collections.list is hard-coded to limit: 100 during name resolution. In workspaces with >100 collections this can produce false “not found”/ambiguity results. Consider paginating (using offset/pagination.nextPath) until completion.

Copilot uses AI. Check for mistakes.
Comment thread src/commands/document.ts Outdated
Comment on lines 127 to 131
.action(async (id: string, opts) => {
const resolvedId = await resolveDocumentId(id);
const { data } = await apiRequest<Document>("documents.info", {
id: resolveId(id),
id: resolvedId,
});
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveDocumentId() may call documents.info internally, and this handler then calls documents.info again with the resolved ID. This creates redundant requests/spinners for ID/URL inputs. Consider using resolveDocumentRef() here (and only fetching documents.info if the resolver returned a list item without text), or refactor the resolver to return both the resolved ID and (optionally) the fetched document.

Copilot uses AI. Check for mistakes.
Comment thread src/commands/document.ts Outdated
Comment on lines 145 to 149
.action(async (id: string) => {
const resolvedId = await resolveDocumentId(id);
const { data } = await apiRequest<Document>("documents.info", {
id: resolveId(id),
id: resolvedId,
});
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveDocumentId() may already perform a documents.info lookup, and open then performs another documents.info call. This can be avoided by reusing the document returned by a resolveDocumentRef()-style resolver, reducing latency and API load for URL/ID inputs.

Copilot uses AI. Check for mistakes.
Comment thread src/commands/collection.ts Outdated
Comment on lines +69 to +74
.action(async (id: string, opts) => {
const { data } = await apiRequest<Collection>("collections.info", { id });
const resolvedId = await resolveCollectionId(id);
const { data } = await apiRequest<Collection>("collections.info", {
id: resolvedId,
});
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveCollectionId() can call collections.info internally, and this get handler then calls collections.info again with the resolved ID, doubling requests for ID inputs and potentially tripling for name inputs that “look like” IDs. Consider using resolveCollectionRef() directly when possible, or refactor the resolver to return both the resolved ID and fetched entity to avoid duplicate lookups.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/refs.ts Outdated
Comment on lines +99 to +103
const exactMatch = items.find(
(item) => getName(item).toLowerCase() === refLower,
);
if (exactMatch) return exactMatch;

Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact name matching uses find, so if multiple documents/collections have the same name/title (case-insensitive) the resolver will silently pick the first match and potentially operate on the wrong entity. Treat multiple exact matches as ambiguous (similar to partial matches) and include suggestions.

Suggested change
const exactMatch = items.find(
(item) => getName(item).toLowerCase() === refLower,
);
if (exactMatch) return exactMatch;
const exactMatches = items.filter(
(item) => getName(item).toLowerCase() === refLower,
);
if (exactMatches.length === 1) {
return exactMatches[0];
}
if (exactMatches.length > 1) {
const suggestions = formatSuggestions(
exactMatches,
getName,
(item) => item.id,
);
throw new Error(
`Ambiguous ${entityType} reference "${ref}". Did you mean:\n${suggestions}`,
);
}

Copilot uses AI. Check for mistakes.
Comment thread src/lib/refs.ts
Comment on lines +114 to +121
const suggestions = formatSuggestions(
partialMatches,
getName,
(item) => item.id,
);
throw new Error(
`Ambiguous ${entityType} reference "${ref}". Did you mean:\n${suggestions}`,
);
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ambiguity suggestions always display item.id. For documents, the CLI generally surfaces urlId (see src/commands/document.ts formatting), so showing the internal id here can be confusing. Consider making resolveRef accept a getDisplayId callback so documents can suggest urlId while collections suggest id.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/refs.ts
Comment on lines +84 to +88
// Try direct ID lookup first if it looks like an ID
const extractedId = extractDocumentId(ref);
if (looksLikeId(extractedId)) {
try {
return await fetchById(extractedId);
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveRef always runs extractDocumentId(ref) before ID lookup, even when resolving collections. This can transform non-document references (e.g., names containing hyphens) into a different string for the initial lookup, causing unnecessary failed API calls and potentially incorrect resolution if an entity’s ID happens to match the extracted suffix. Consider making the “extract slug id” step optional/per-entity (documents only).

Suggested change
// Try direct ID lookup first if it looks like an ID
const extractedId = extractDocumentId(ref);
if (looksLikeId(extractedId)) {
try {
return await fetchById(extractedId);
// Try direct ID lookup first if it looks like an ID.
// Only apply slug/document-ID extraction for document-like entity types
// to avoid corrupting non-document references (e.g. collection names).
const maybeDocType = entityType.toLowerCase();
const idCandidate =
maybeDocType.startsWith("document") ? extractDocumentId(ref) : ref;
if (looksLikeId(idCandidate)) {
try {
return await fetchById(idCandidate);

Copilot uses AI. Check for mistakes.
Comment thread src/lib/refs.ts Outdated
Comment on lines +138 to +142
async () => {
const { data } = await apiRequest<Document[]>("documents.list", {
limit: 100,
});
return data;
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documents.list is hard-coded to limit: 100 during name resolution. In workspaces with >100 documents this can produce false “not found”/ambiguity results. Consider paginating until completion (using offset/pagination.nextPath) or using documents.search to narrow results.

Copilot uses AI. Check for mistakes.
Ubuntu and others added 2 commits January 25, 2026 12:38
Add a new refs module that resolves document and collection references by:
- Exact ID (UUID or short alphanumeric format)
- URL slug extraction (e.g., "my-doc-abc123" -> "abc123")
- Exact name match (case-insensitive)
- Partial name match (if only one result matches)
- Helpful error messages with suggestions for ambiguous matches

Update document and collection commands to use the new resolvers,
allowing users to reference items by name instead of just ID.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add pagination to documents.list and collections.list to support
  workspaces with >100 items
- Treat multiple exact name matches as ambiguous instead of silently
  picking first
- Show urlId instead of internal id in ambiguous document suggestions
- Make slug extraction document-only to prevent incorrect collection
  name transformations
- Reuse resolved document/collection objects in get commands to avoid
  redundant API calls

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@gnapse gnapse merged commit 6c1c6f9 into main Jan 25, 2026
2 checks passed
github-actions Bot pushed a commit that referenced this pull request Mar 25, 2026
# 1.0.0 (2026-03-25)

### Bug Fixes

* broaden CI detection to handle all truthy values ([7dc2806](7dc2806))
* exclude dist from biome checks ([0dc49a3](0dc49a3))

### Features

* add API Spinner Proxy ([#17](#17)) ([a3bc75a](a3bc75a))
* add fuzzy reference resolution ([#19](#19)) ([6c1c6f9](6c1c6f9))
* add npm publishing, new agent skills, and skill auto-update ([#36](#36)) ([bcb2e75](bcb2e75)), closes [Doist/todoist-cli#176](Doist/todoist-cli#176) [Doist/bob-cli#17](Doist/bob-cli#17) [Doist/twist-cli#101](Doist/twist-cli#101)
* add structured error formatting with codes and hints ([#18](#18)) ([17658d2](17658d2))
* implement OAuth PKCE browser login ([#20](#20)) ([b7f6eec](b7f6eec)), closes [#7](#7) [outline/outline#11254](outline/outline#11254)
* improve oauth login inputs and callback UX ([#31](#31)) ([3011b28](3011b28))
@github-actions
Copy link
Copy Markdown

🎉 This PR is included in version 1.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add fuzzy reference resolution for documents and collections

2 participants