Created
April 13, 2026 20:30
-
-
Save saoudrizwan/2dc96223261d348575df0f4a81cf580a to your computer and use it in GitHub Desktop.
A TypeScript LSP plugin for Cline CLI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { createRequire } from "node:module"; | |
| import { resolve, dirname, join } from "node:path"; | |
| import { existsSync } from "node:fs"; | |
| /** | |
| * Cline CLI plugin: TypeScript Navigation | |
| * | |
| * Provides go-to-definition using the TypeScript Language Service API. | |
| * Resolves `typescript` from the target project's node_modules at runtime. | |
| */ | |
| type LanguageServiceCache = { | |
| tsconfigPath: string; | |
| service: ReturnType<typeof createService>; | |
| ts: typeof import("typescript"); | |
| }; | |
| let cache: LanguageServiceCache | undefined; | |
| function findTsConfig(startDir: string): string | undefined { | |
| let dir = startDir; | |
| while (true) { | |
| const candidate = join(dir, "tsconfig.json"); | |
| if (existsSync(candidate)) return candidate; | |
| const parent = dirname(dir); | |
| if (parent === dir) return undefined; | |
| dir = parent; | |
| } | |
| } | |
| function loadTypeScript(projectDir: string) { | |
| const req = createRequire(resolve(projectDir, "package.json")); | |
| const tsPath = req.resolve("typescript"); | |
| return req(tsPath) as typeof import("typescript"); | |
| } | |
| function createService(ts: typeof import("typescript"), tsconfigPath: string) { | |
| const projectDir = dirname(tsconfigPath); | |
| const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); | |
| if (configFile.error) { | |
| throw new Error( | |
| "Failed to read tsconfig.json: " + | |
| ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"), | |
| ); | |
| } | |
| const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, projectDir); | |
| const host: import("typescript").LanguageServiceHost = { | |
| getScriptFileNames: () => parsed.fileNames, | |
| getScriptVersion: () => "1", | |
| getScriptSnapshot: (fileName) => { | |
| const content = ts.sys.readFile(fileName); | |
| if (content === undefined) return undefined; | |
| return ts.ScriptSnapshot.fromString(content); | |
| }, | |
| getCurrentDirectory: () => projectDir, | |
| getCompilationSettings: () => parsed.options, | |
| getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options), | |
| fileExists: ts.sys.fileExists, | |
| readFile: ts.sys.readFile, | |
| readDirectory: ts.sys.readDirectory, | |
| getDirectories: ts.sys.getDirectories, | |
| }; | |
| return ts.createLanguageService(host, ts.createDocumentRegistry()); | |
| } | |
| function getOrCreateService(tsconfigPath: string) { | |
| if (cache && cache.tsconfigPath === tsconfigPath) { | |
| return cache; | |
| } | |
| const projectDir = dirname(tsconfigPath); | |
| const ts = loadTypeScript(projectDir); | |
| const service = createService(ts, tsconfigPath); | |
| cache = { tsconfigPath, service, ts }; | |
| return cache; | |
| } | |
| function offsetToLineCol( | |
| sourceFile: import("typescript").SourceFile, | |
| ts: typeof import("typescript"), | |
| offset: number, | |
| ) { | |
| const lc = ts.getLineAndCharacterOfPosition(sourceFile, offset); | |
| return { line: lc.line + 1, column: lc.character + 1 }; | |
| } | |
| /** | |
| * Find all identifier positions on a given line by scanning the AST. | |
| */ | |
| function getIdentifierOffsetsOnLine( | |
| ts: typeof import("typescript"), | |
| sourceFile: import("typescript").SourceFile, | |
| targetLine: number, | |
| ) { | |
| const offsets: number[] = []; | |
| function visit(node: import("typescript").Node) { | |
| if (ts.isIdentifier(node)) { | |
| const lc = ts.getLineAndCharacterOfPosition(sourceFile, node.getStart(sourceFile)); | |
| if (lc.line + 1 === targetLine) { | |
| offsets.push(node.getStart(sourceFile)); | |
| } | |
| } | |
| ts.forEachChild(node, visit); | |
| } | |
| visit(sourceFile); | |
| return offsets; | |
| } | |
| const plugin = { | |
| name: "typescript-nav", | |
| manifest: { | |
| capabilities: ["tools"], | |
| }, | |
| setup(api: { | |
| registerTool: (tool: { | |
| name: string; | |
| description: string; | |
| inputSchema: Record<string, unknown>; | |
| timeoutMs?: number; | |
| retryable?: boolean; | |
| execute: (input: unknown, context: unknown) => Promise<unknown>; | |
| }) => void; | |
| }) { | |
| api.registerTool({ | |
| name: "goto_definition", | |
| description: | |
| "Find where TypeScript/JavaScript symbols on a given line are defined. Given a file path and line number, finds all identifiers on that line and resolves their definitions. Much more precise than text search -- resolves through imports, re-exports, type aliases, etc.", | |
| inputSchema: { | |
| type: "object", | |
| properties: { | |
| file: { | |
| type: "string", | |
| description: "Absolute path to the file.", | |
| }, | |
| line: { | |
| type: "integer", | |
| description: "Line number (1-based).", | |
| }, | |
| }, | |
| required: ["file", "line"], | |
| additionalProperties: false, | |
| }, | |
| timeoutMs: 30000, | |
| retryable: false, | |
| async execute(input) { | |
| const args = input as { file: string; line: number }; | |
| const fileName = resolve(args.file); | |
| if (!existsSync(fileName)) { | |
| throw new Error("File does not exist: " + fileName); | |
| } | |
| const tsconfigPath = findTsConfig(dirname(fileName)); | |
| if (!tsconfigPath) { | |
| throw new Error( | |
| "No tsconfig.json found in any parent directory of " + fileName, | |
| ); | |
| } | |
| const { ts, service } = getOrCreateService(tsconfigPath); | |
| const program = service.getProgram(); | |
| if (!program) throw new Error("Failed to create TypeScript program"); | |
| const sourceFile = program.getSourceFile(fileName); | |
| if (!sourceFile) { | |
| throw new Error( | |
| "File not found in TypeScript program. Make sure it is included by tsconfig.json: " + | |
| fileName, | |
| ); | |
| } | |
| const identifierOffsets = getIdentifierOffsetsOnLine(ts, sourceFile, args.line); | |
| if (identifierOffsets.length === 0) { | |
| return { | |
| found: false, | |
| file: args.file, | |
| line: args.line, | |
| message: "No identifiers found on this line.", | |
| }; | |
| } | |
| const results: Array<{ | |
| symbol: string; | |
| definitions: Array<{ | |
| file: string; | |
| line: number; | |
| column: number; | |
| kind: string; | |
| name: string; | |
| containerName?: string; | |
| }>; | |
| }> = []; | |
| const seen = new Set<string>(); | |
| for (const offset of identifierOffsets) { | |
| const token = ts.getTokenAtPosition(sourceFile, offset); | |
| const symbolName = token.getText(sourceFile); | |
| // skip duplicates on the same line | |
| if (seen.has(symbolName)) continue; | |
| seen.add(symbolName); | |
| const definitions = service.getDefinitionAtPosition(fileName, offset); | |
| if (!definitions || definitions.length === 0) continue; | |
| // skip symbols that are defined right here on this line (self-references) | |
| const nonSelfDefs = definitions.filter((def) => { | |
| if (def.fileName !== fileName) return true; | |
| const defLine = offsetToLineCol(sourceFile, ts, def.textSpan.start); | |
| return defLine.line !== args.line; | |
| }); | |
| if (nonSelfDefs.length === 0) continue; | |
| results.push({ | |
| symbol: symbolName, | |
| definitions: nonSelfDefs.map((def) => { | |
| const defSourceFile = program.getSourceFile(def.fileName); | |
| const loc = defSourceFile | |
| ? offsetToLineCol(defSourceFile, ts, def.textSpan.start) | |
| : { line: 0, column: 0 }; | |
| return { | |
| file: def.fileName, | |
| line: loc.line, | |
| column: loc.column, | |
| kind: def.kind, | |
| name: def.name, | |
| containerName: def.containerName || undefined, | |
| }; | |
| }), | |
| }); | |
| } | |
| if (results.length === 0) { | |
| return { | |
| found: false, | |
| file: args.file, | |
| line: args.line, | |
| message: | |
| "Identifiers found on this line but none resolved to external definitions.", | |
| }; | |
| } | |
| return { | |
| found: true, | |
| query: { file: args.file, line: args.line }, | |
| tsconfig: tsconfigPath, | |
| results, | |
| }; | |
| }, | |
| }); | |
| }, | |
| }; | |
| export default plugin; | |
| export { plugin }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment