首页
/ Cline SDK TypeScript LSP 插件实战:为 Agent 注入语言服务级的 goto_definition 导航工具

Cline SDK TypeScript LSP 插件实战:为 Agent 注入语言服务级的 goto_definition 导航工具

2026-09-06 17:18:46作者:尤峻淳Whitney

本文以 Cline SDK 官方示例插件 typescript-lsp 为对象,完整讲解如何用 TypeScript Language Service API 为 Cline Agent 注册一个 goto_definition(file, line) 工具:从插件的注册结构(AgentPlugin + createTool)、Language Service 的构建与缓存,到通过 CLI 安装和直接运行 demo 的完整流程。读完后你可以独立编写类似"封装内部 API 为插件工具"的扩展,并理解它为何比 grep/文本搜索精准得多。

插件解决了什么问题

Agent 在大代码库中定位符号时,如果只有文本搜索能力,会出现三类典型错误:找不到真正的定义(命中的是引用或注释)、无法区分原始定义与 re-export、无法解析包别名(如 @cline/shared)背后的真实源文件。

Cline SDK 自带的 typescript-lsp 插件给出的方案是:注册一个由 TypeScript Language Service API 驱动的工具,让 Agent 走和 IDE "Go to Definition" 完全相同的语义通道——穿透 import、re-export、类型别名和声明合并来解析符号。

插件只向 Agent 暴露一个工具 goto_definition(file, line):给定文件路径和行号,它会找出该行上的所有标识符,并解析它们真正的定义位置。以一条 import 行为例:

import { disposeAll, initVcr } from "@cline/shared"

插件会沿着 workspace 的包别名把两个符号都解析到源码文件:

disposeAll -> packages/shared/src/dispose.ts:19
initVcr    -> packages/shared/src/vcr.ts:699

这也是官方插件示例集中把它列为重点示例的原因。在 插件示例总览中,typescript-lsp 被描述为"加载目标项目自己的 TypeScript 版本、找出某行上的标识符、并借助语言服务语义解析定义"的工具型插件,与 weather-metricsbackground-terminal 等共同覆盖 Cline 插件的四大能力:注册工具、生命周期 Hook、消息重写、自动化事件。

插件结构:AgentPlugin + createTool

整个插件就是一个 TypeScript 文件,入口在 index.ts。它的顶层结构是一个 AgentPlugin 对象(文件末尾同时以具名和默认方式导出,第 297–298 行):

const plugin: AgentPlugin = {
  name: "typescript-lsp",
  manifest: {
    capabilities: ["tools"],
  },

  setup(api) {
    api.registerTool(
      createTool({
        name: "goto_definition",
        description: "Find where TypeScript/JavaScript symbols on a given line are defined...",
        inputSchema: {
          type: "object",
          properties: {
            file: { type: "string", description: "Absolute path to the file." },
            line: { type: "integer", description: "Line number (1-based)." },
          },
          required: ["file", "line"],
        },
        async execute(input) {
          // 1. 从文件向上查找 tsconfig.json
          // 2. 创建(或复用缓存的)TypeScript Language Service
          // 3. 扫描 AST 找出目标行上的所有标识符
          // 4. 通过 Language Service 解析每个标识符的定义
          // 5. 过滤掉自引用,返回位置信息
        },
      }),
    );
  },
};

这里的每个字段都能在 SDK 共享类型中找到对应实现:

  • manifest.capabilities:插件必须声明自己使用哪些能力。在 contribution-registry.ts 中,合法能力值为 "hooks" | "tools" | "commands" | "rules" | "skills" | "messageBuilders" | "providers" | "automationEvents" | "mcp",其中声明 tools 才会解锁 api.registerTool()。注册表在 setup() 执行前会校验"声明的能力必须存在对应实现,反之也不允许出现未声明的处理器"(见 ContributionRegistryExtension 的 JSDoc,第 250–276 行)。
  • createTool:由 @cline/core 导出,实现在 shared 包。它接收 namedescriptioninputSchema(JSON Schema 对象或 Zod schema)、execute 四个核心参数,另有三个有默认值的可选项——timeoutMs 默认 30_000retryable 默认 truemaxRetries 默认 3。typescript-lsp 显式传了 timeoutMs: 30000, retryable: falseindex.ts 第 186–187 行),含义是:Language Service 解析最多等 30 秒,且失败时不自动重试(解析失败通常是文件未包含在 tsconfig 中等确定性错误,重试无意义)。
  • inputSchema 约束createTool 内部的 normalizeToolInputSchema 会强制顶层必须是 object 形状,并剥掉 Zod 序列化产生的 $schema 元键;对 oneOf/anyOf/allOf 分支,要求每个分支都显式断言 type: "object",否则在注册期直接抛错。这解释了为什么插件里 inputSchema 直接写成 { type: "object", properties: {...}, required: [...], additionalProperties: false } 这种严格的 object schema。

内部实现:六个步骤构建精准导航

execute 的完整流程(index.ts 第 188–291 行)可以拆成六个环节,下面逐一对应源码实现。

1. findTsConfig:向上查找最近的 tsconfig.json

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;
	}
}

从目标文件所在目录逐级向上,直到找到 tsconfig.json 或到达文件系统根。找不到会抛出 No tsconfig.json found in any parent directory 错误——这界定了插件的适用前提:目标项目必须是 TypeScript 项目且存在 tsconfig。

2. loadTypeScript:复用目标项目的 TypeScript 版本

function loadTypeScript(projectDir: string) {
	const req = createRequire(resolve(projectDir, "package.json"));
	const tsPath = req.resolve("typescript");
	return req(tsPath) as typeof import("typescript");
}

关键点是用 createRequire() 以目标项目的 package.json 为基准解析 typescript 模块。也就是说,插件自身零依赖(只用到 node:fsnode:modulenode:path 等 Node 内建模块),但运行时使用的 TS 版本与目标项目编译所用版本一致,避免"用新 TS 解析老项目"带来的 API 行为差异。

3. createLanguageService:用项目编译选项构建 Language Service

const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
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,
	// ...
};
return ts.createLanguageService(host, ts.createDocumentRegistry());

LanguageServiceHost 是 TS 语言服务与文件系统之间的适配层:脚本列表直接取 parsed.fileNames(即 tsconfig 的 include 展开结果),编译选项取 parsed.options。因此插件的解析行为——包括包别名、路径映射、模块解析策略——完全继承项目自身的 tsconfig 配置,这正是它能解析 @cline/shared 这类 workspace 包别名的根本原因。tsconfig.json 读取失败时会把诊断信息扁平化后抛出,错误可定位到具体配置问题。

4. 缓存:同一会话内复用 Service

function getOrCreateService(tsconfigPath: string) {
	if (cache && cache.tsconfigPath === tsconfigPath) {
		return cache;
	}
	const projectDir = dirname(tsconfigPath);
	const ts = loadTypeScript(projectDir);
	const service = createLanguageService(ts, tsconfigPath);
	cache = { tsconfigPath, service, ts };
	return cache;
}

模块级变量 cache 记录 { tsconfigPath, service, ts } 三元组,只有 tsconfig 路径变化时才重建。第一次调用要付出解析整个项目的代价,后续调用命中缓存。

这里有一个值得注意的生命周期细节:官方插件文档说明了沙箱插件在空闲 30 分钟(可用 CLINE_PLUGIN_IDLE_TIMEOUT_MS 调整)后子进程会被回收,下次调用会重新执行 setup()。因此这个模块级缓存应当视为缓存而非持久存储——空闲驱逐、宿主重启或沙箱崩溃后都会重置,需要跨这些边界存活的状态应落盘。

5. getIdentifiersOnLine:AST 扫描目标行

function getIdentifiersOnLine(ts, sourceFile, targetLine) {
	const identifiers: Array<{ offset: number; name: string }> = [];
	function visit(node) {
		if (ts.isIdentifier(node)) {
			const lc = ts.getLineAndCharacterOfPosition(sourceFile, node.getStart(sourceFile));
			if (lc.line + 1 === targetLine) {
				identifiers.push({ offset: node.getStart(sourceFile), name: node.text });
			}
		}
		ts.forEachChild(node, visit);
	}
	visit(sourceFile);
	return identifiers;
}

它遍历整个 SourceFile 的 AST,收集 ts.isIdentifier 命中的节点,并按"节点起始偏移换算出的行号(1-based)等于目标行"过滤。相比按文本切行,AST 扫描天然排除了字符串、注释中的干扰文本——"找一行里的标识符"本身也是语义行为。

6. getDefinitionAtPosition:解析定义并过滤自引用

const definitions = service.getDefinitionAtPosition(fileName, offset);
const nonSelfDefs = definitions.filter((def) => {
	if (def.fileName !== fileName) return true;
	const defLine = offsetToLineCol(sourceFile, ts, def.textSpan.start);
	return defLine.line !== line;
});

service.getDefinitionAtPosition() 是语言服务的核心查询入口,负责穿透 import、re-export、类型别名等语义。返回的每个定义被转换回 1-based 的行列号(内部 getLineAndCharacterOfPosition 是 0-based,offsetToLineCol 统一 +1),并附带 kind(定义符号的种类)和 containerName(外层容器名,如类/命名空间)。过滤自引用的规则是:定义若与查询位置同文件且同行为同一行,则丢弃——避免"定义就在此行"这类对 Agent 无信息量的结果。同一行的同名标识符还会经 seen Set 去重。

工具的输出形态有三种分支:无标识符时返回 { found: false, message: "No identifiers found on this line." };有标识符但都解析不到外部定义时返回相应的说明性消息;成功时返回 { found: true, query, tsconfig, results },其中 results{ symbol, definitions: [{ file, line, column, kind, name, containerName? }] } 数组。另外两个前置校验也值得注意:文件不存在直接抛错;文件未被 tsconfig 的 include 覆盖时,program.getSourceFile(fileName) 返回空并抛出 File not found in TypeScript program. Make sure it is included by tsconfig.json

两种使用方式:CLI 安装与直接运行 demo

方式一:通过 Cline CLI 安装

cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/typescript-lsp/index.ts
cline -i "Find where createTool is defined"

cline plugin install 支持单文件、GitHub 文件 URL、包目录、git 仓库和 npm 包等多种形式(见 插件示例总览 中 "Try it with the CLI" 一节)。CLI 会自动从工作区的 .cline/plugins、用户主目录的 ~/.cline/plugins 以及系统 Plugins 文件夹发现插件。由于插件运行时从目标项目node_modules 解析 typescript,安装后无需在插件侧添加任何依赖。

方式二:直接运行 demo 脚本

ANTHROPIC_API_KEY=sk-... bun run examples/plugins/typescript-lsp/index.ts

index.ts 同时是一份可执行的 demo 脚本(bun run 时以 @cline/sdkClineCore 创建 host 并把插件挂进 extensions),无需预先安装为插件即可观察 goto_definition 的端到端行为。

方式三:以 SDK 扩展的形式接入

如果基于 @cline/core 构建自己的 Agent 宿主,把插件对象直接放进 ClineCore.start()config.extensions 即可:

const host = await ClineCore.create({ backendMode: "local" });
await host.start({
  config: {
    providerId: "anthropic",
    modelId: "claude-sonnet-4-6",
    apiKey: process.env.ANTHROPIC_API_KEY ?? "",
    cwd: process.cwd(),
    enableTools: true,
    systemPrompt: "You are a helpful assistant.",
    extensions: [plugin],
  },
  prompt: "Find where createTool is defined",
  interactive: false,
});

从源码结构看,extensionsAgentConfig 中的扩展列表类型贯通(agents/types.ts 定义了 extensions?: AgentExtension[]),注册表在初始化阶段执行每个扩展的 setup(api, ctx) 并汇总其贡献的工具,随后这些工具与 Core 内置工具一样进入模型的 tool 列表。

适用边界与限制

结合 index.ts 源码,可以归纳出该插件的明确边界:

  1. 仅面向 TypeScript/JavaScript 项目:无 tsconfig.json(且不在任意父目录中)时直接报错,纯 Python/Go 仓库不适用。
  2. 文件必须在 tsconfig 的包含范围内program.getSourceFile() 查不到文件会报 "Make sure it is included by tsconfig.json",被排除(exclude)的目录解析不了。
  3. 30 秒超时、不重试:超大 monorepo 首次构建 Language Service 可能接近 timeoutMs 上限,但缓存后后续调用显著变快。
  4. 模块级缓存在沙箱回收后失效:按上文生命周期说明,空闲驱逐后状态重置,插件行为仍是正确的(只是重新付出首次构建代价),但插件作者不应依赖它保存业务状态。
  5. 一次查询一行goto_definition 只解析传入的那一行上的标识符,跨多行的 import 块需要按行分别查询。

参考模式:把内部系统封装成 Agent 工具

官方文档把这个示例的意义概括为:文本搜索能找到符号名,但无法区分定义、引用、re-export 和被遮蔽的变量,而 TypeScript Language Service 能处理这些。同一个模式可以直接迁移到企业内部场景——把内部 API、部署系统、功能开关、事件管理、CI 流水线等团队日常操作的系统封装成插件工具。

落地时只需照搬本示例的三段式结构:

  1. 声明最小能力manifest.capabilities: ["tools"],声明即契约,注册表会做双向校验;
  2. 严格定义工具契约inputSchema 保持顶层 object 形状、显式 requiredadditionalProperties: false,并针对"确定性失败不重试、耗时长操作设超时"调整 retryable/timeoutMs(默认分别为 true/30000,可对照 createTool 签名 取值);
  3. 把昂贵的资源构建放进模块级缓存:如 Language Service 之于本项目,注意它是"缓存而非存储",跨沙箱边界的持久化另作设计。

一个插件就是一个 TypeScript 文件——不需要托管和维护一个 MCP server,cline plugin install 一条命令即可让所有 Cline 宿主(CLI、VS Code 等)获得新能力。

登录后查看全文
热门项目推荐
相关项目推荐