@ai-sdk/harness-cline 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ import type { AgentTool } from '@cline/agents';
2
+ import type {
3
+ CreateMcpToolsOptions,
4
+ McpManager,
5
+ McpManagerOptions,
6
+ McpServerClientFactory,
7
+ McpServerRegistration,
8
+ McpServerTransportConfig,
9
+ } from '@cline/core';
10
+
11
+ const CLINE_CORE_PACKAGE: string = '@cline/core';
12
+
13
+ type ClineCoreMcpModule = {
14
+ createDefaultMcpServerClientFactory(): McpServerClientFactory;
15
+ createMcpTools(options: CreateMcpToolsOptions): Promise<AgentTool[]>;
16
+ InMemoryMcpManager: new (options: McpManagerOptions) => McpManager;
17
+ };
18
+
19
+ export interface ClineMcpRuntime {
20
+ readonly tools: ReadonlyArray<AgentTool>;
21
+ readonly toolNames: ReadonlySet<string>;
22
+ dispose(): Promise<void>;
23
+ }
24
+
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return value != null && typeof value === 'object' && !Array.isArray(value);
27
+ }
28
+
29
+ function optionalStringArray(value: unknown): string[] | undefined {
30
+ return Array.isArray(value) && value.every(item => typeof item === 'string')
31
+ ? value
32
+ : undefined;
33
+ }
34
+
35
+ function optionalStringRecord(
36
+ value: unknown,
37
+ ): Record<string, string> | undefined {
38
+ return isRecord(value) &&
39
+ Object.values(value).every(item => typeof item === 'string')
40
+ ? (value as Record<string, string>)
41
+ : undefined;
42
+ }
43
+
44
+ function resolveTransport(input: {
45
+ serverName: string;
46
+ config: Record<string, unknown>;
47
+ }): McpServerTransportConfig {
48
+ if (isRecord(input.config.transport)) {
49
+ return input.config.transport as unknown as McpServerTransportConfig;
50
+ }
51
+
52
+ if (typeof input.config.command === 'string') {
53
+ const args = optionalStringArray(input.config.args);
54
+ const env = optionalStringRecord(input.config.env);
55
+ return {
56
+ type: 'stdio',
57
+ command: input.config.command,
58
+ ...(args ? { args } : {}),
59
+ ...(typeof input.config.cwd === 'string'
60
+ ? { cwd: input.config.cwd }
61
+ : {}),
62
+ ...(env ? { env } : {}),
63
+ };
64
+ }
65
+
66
+ if (typeof input.config.url === 'string') {
67
+ const configuredType = input.config.type ?? input.config.transportType;
68
+ const headers = optionalStringRecord(input.config.headers);
69
+ return {
70
+ type:
71
+ configuredType === 'http' || configuredType === 'streamableHttp'
72
+ ? 'streamableHttp'
73
+ : 'sse',
74
+ url: input.config.url,
75
+ ...(headers ? { headers } : {}),
76
+ };
77
+ }
78
+
79
+ throw new Error(
80
+ `Cline MCP server ${JSON.stringify(input.serverName)} must define a transport, command, or URL.`,
81
+ );
82
+ }
83
+
84
+ function resolveRegistration(input: {
85
+ serverName: string;
86
+ config: unknown;
87
+ }): McpServerRegistration {
88
+ if (!isRecord(input.config)) {
89
+ throw new Error(
90
+ `Cline MCP server ${JSON.stringify(input.serverName)} must be configured with an object value.`,
91
+ );
92
+ }
93
+
94
+ return {
95
+ name: input.serverName,
96
+ transport: resolveTransport({
97
+ serverName: input.serverName,
98
+ config: input.config,
99
+ }),
100
+ ...(typeof input.config.disabled === 'boolean'
101
+ ? { disabled: input.config.disabled }
102
+ : {}),
103
+ ...(isRecord(input.config.metadata)
104
+ ? { metadata: input.config.metadata }
105
+ : {}),
106
+ ...(isRecord(input.config.oauth) ? { oauth: input.config.oauth } : {}),
107
+ };
108
+ }
109
+
110
+ export async function createClineMcpRuntime(input: {
111
+ mcpServers: Record<string, unknown> | undefined;
112
+ }): Promise<ClineMcpRuntime> {
113
+ if (input.mcpServers == null || Object.keys(input.mcpServers).length === 0) {
114
+ return {
115
+ tools: [],
116
+ toolNames: new Set(),
117
+ dispose: async () => {},
118
+ };
119
+ }
120
+
121
+ const {
122
+ createDefaultMcpServerClientFactory,
123
+ createMcpTools,
124
+ InMemoryMcpManager,
125
+ } = (await import(CLINE_CORE_PACKAGE)) as ClineCoreMcpModule;
126
+ const manager = new InMemoryMcpManager({
127
+ clientFactory: createDefaultMcpServerClientFactory(),
128
+ });
129
+ const tools: AgentTool[] = [];
130
+
131
+ try {
132
+ for (const [serverName, config] of Object.entries(input.mcpServers)) {
133
+ const registration = resolveRegistration({ serverName, config });
134
+ await manager.registerServer(registration);
135
+ if (registration.disabled) continue;
136
+ tools.push(
137
+ ...(await createMcpTools({
138
+ serverName,
139
+ provider: manager,
140
+ })),
141
+ );
142
+ }
143
+ } catch (error) {
144
+ await manager.dispose().catch(() => {});
145
+ throw error;
146
+ }
147
+
148
+ return {
149
+ tools,
150
+ toolNames: new Set(tools.map(tool => tool.name)),
151
+ dispose: async () => manager.dispose(),
152
+ };
153
+ }
@@ -0,0 +1,243 @@
1
+ import path from 'node:path';
2
+ import { shellQuote } from '@ai-sdk/harness/utils';
3
+ import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
4
+
5
+ /**
6
+ * Sandbox-backed implementations of the Cline harness's built-in tools.
7
+ *
8
+ * The Cline runtime executes on the host, but the workspace the model reasons
9
+ * about lives in the sandbox — every file/shell operation goes through the
10
+ * restricted `SandboxSession` surface. Relative paths resolve against the
11
+ * per-session working directory the framework created.
12
+ */
13
+ export interface ClineRemoteOpsOptions {
14
+ readonly sandbox: Experimental_SandboxSession;
15
+ /** Absolute sandbox path of the session's working directory. */
16
+ readonly workDir: string;
17
+ }
18
+
19
+ export interface ClineRemoteOps {
20
+ resolvePath(inputPath: string): string;
21
+ readFile(inputPath: string): Promise<string>;
22
+ writeFile(inputPath: string, content: string): Promise<void>;
23
+ editFile(inputPath: string, oldText: string, newText: string): Promise<void>;
24
+ bash(
25
+ command: string,
26
+ input?: {
27
+ /** Timeout in seconds (the `bash` tool contract). */
28
+ timeout?: number;
29
+ signal?: AbortSignal;
30
+ },
31
+ ): Promise<{ output: string; exitCode: number }>;
32
+ grep(
33
+ pattern: string,
34
+ input?: {
35
+ path?: string;
36
+ glob?: string;
37
+ ignoreCase?: boolean;
38
+ literal?: boolean;
39
+ context?: number;
40
+ limit?: number;
41
+ },
42
+ ): Promise<string>;
43
+ glob(pattern: string, inputPath?: string, limit?: number): Promise<string[]>;
44
+ ls(inputPath?: string, limit?: number): Promise<string[]>;
45
+ }
46
+
47
+ export function createClineRemoteOps(
48
+ options: ClineRemoteOpsOptions,
49
+ ): ClineRemoteOps {
50
+ const { sandbox, workDir } = options;
51
+
52
+ const resolvePath = (inputPath: string): string => {
53
+ const normalized = path.posix.isAbsolute(inputPath)
54
+ ? path.posix.normalize(inputPath)
55
+ : path.posix.normalize(path.posix.join(workDir, inputPath));
56
+ return normalized;
57
+ };
58
+
59
+ const runShell = async (
60
+ command: string,
61
+ input?: { signal?: AbortSignal },
62
+ ): Promise<{ exitCode: number; output: string }> => {
63
+ // `sandbox.run({ command })` already wraps in a shell; interpolated
64
+ // paths/values inside `command` are quoted with `shellQuote` by callers.
65
+ const result = await sandbox.run({
66
+ command,
67
+ workingDirectory: workDir,
68
+ ...(input?.signal ? { abortSignal: input.signal } : {}),
69
+ });
70
+ return {
71
+ exitCode: result.exitCode,
72
+ output: `${result.stdout}${result.stderr}`,
73
+ };
74
+ };
75
+
76
+ const readFile = async (inputPath: string): Promise<string> => {
77
+ const resolved = resolvePath(inputPath);
78
+ const content = await sandbox.readTextFile({ path: resolved });
79
+ if (content == null) {
80
+ throw new Error(`File not found: ${inputPath}`);
81
+ }
82
+ return content;
83
+ };
84
+
85
+ const writeFile = async (
86
+ inputPath: string,
87
+ content: string,
88
+ ): Promise<void> => {
89
+ const resolved = resolvePath(inputPath);
90
+ // `writeTextFile` creates parent directories recursively per the
91
+ // SandboxSession contract, so no explicit mkdir is needed.
92
+ await sandbox.writeTextFile({ path: resolved, content });
93
+ };
94
+
95
+ const editFile = async (
96
+ inputPath: string,
97
+ oldText: string,
98
+ newText: string,
99
+ ): Promise<void> => {
100
+ const current = await readFile(inputPath);
101
+ const index = current.indexOf(oldText);
102
+ if (index === -1) {
103
+ throw new Error(`Text to replace was not found in ${inputPath}`);
104
+ }
105
+ const updated = `${current.slice(0, index)}${newText}${current.slice(
106
+ index + oldText.length,
107
+ )}`;
108
+ await writeFile(inputPath, updated);
109
+ };
110
+
111
+ return {
112
+ resolvePath,
113
+ readFile,
114
+ writeFile,
115
+ editFile,
116
+
117
+ async bash(command, input) {
118
+ const controller = new AbortController();
119
+ // `input.timeout` is expressed in seconds (the `bash` tool contract),
120
+ // so convert to milliseconds for `setTimeout`.
121
+ const timeoutId =
122
+ typeof input?.timeout === 'number' && input.timeout > 0
123
+ ? setTimeout(() => controller.abort(), input.timeout * 1000)
124
+ : undefined;
125
+
126
+ const forwardedSignal = input?.signal;
127
+ const onAbort = () => controller.abort();
128
+ forwardedSignal?.addEventListener('abort', onAbort, { once: true });
129
+
130
+ try {
131
+ const result = await runShell(command, { signal: controller.signal });
132
+ return { output: result.output, exitCode: result.exitCode };
133
+ } finally {
134
+ forwardedSignal?.removeEventListener('abort', onAbort);
135
+ if (timeoutId) {
136
+ clearTimeout(timeoutId);
137
+ }
138
+ }
139
+ },
140
+
141
+ async grep(pattern, input = {}) {
142
+ const target = resolvePath(input.path ?? '.');
143
+ const flags = [
144
+ '-R',
145
+ '-n',
146
+ '--binary-files=without-match',
147
+ ...(input.ignoreCase ? ['-i'] : []),
148
+ ...(input.literal ? ['-F'] : []),
149
+ ...(typeof input.context === 'number' && input.context > 0
150
+ ? ['-C', String(input.context)]
151
+ : []),
152
+ ...(input.glob ? ['--include', input.glob] : []),
153
+ ];
154
+ const limit = Math.max(1, input.limit ?? 100);
155
+ const result = await runShell(
156
+ [
157
+ `if [ ! -e ${shellQuote(
158
+ target,
159
+ )} ]; then echo "__CLINE_GREP_NOT_FOUND__"; exit 2; fi`,
160
+ `grep ${flags.map(shellQuote).join(' ')} -- ${shellQuote(
161
+ pattern,
162
+ )} ${shellQuote(target)} 2>/dev/null | head -n ${limit}`,
163
+ ].join('; '),
164
+ );
165
+
166
+ const output = result.output.trim();
167
+ if (output.includes('__CLINE_GREP_NOT_FOUND__')) {
168
+ throw new Error(`Path not found: ${input.path ?? '.'}`);
169
+ }
170
+ return output || 'No matches found';
171
+ },
172
+
173
+ async glob(pattern, inputPath = '.', limit = 1_000) {
174
+ const target = resolvePath(inputPath);
175
+ const result = await runShell(
176
+ [
177
+ `if [ ! -e ${shellQuote(
178
+ target,
179
+ )} ]; then echo "__CLINE_FIND_NOT_FOUND__"; exit 2; fi`,
180
+ `if [ -d ${shellQuote(target)} ]; then find ${shellQuote(
181
+ target,
182
+ )} -type f -print; else printf '%s\\n' ${shellQuote(target)}; fi`,
183
+ ].join('; '),
184
+ );
185
+
186
+ const output = result.output.trim();
187
+ if (output.includes('__CLINE_FIND_NOT_FOUND__')) {
188
+ throw new Error(`Path not found: ${inputPath}`);
189
+ }
190
+
191
+ return output
192
+ .split('\n')
193
+ .filter(Boolean)
194
+ .map(absolutePath => {
195
+ if (absolutePath === target) {
196
+ return path.posix.basename(absolutePath);
197
+ }
198
+ return path.posix.relative(target, absolutePath);
199
+ })
200
+ .filter(
201
+ candidate =>
202
+ candidate.length > 0 && path.matchesGlob(candidate, pattern),
203
+ )
204
+ .sort((left, right) =>
205
+ left.toLowerCase().localeCompare(right.toLowerCase()),
206
+ )
207
+ .slice(0, limit);
208
+ },
209
+
210
+ async ls(inputPath = '.', limit = 500) {
211
+ const target = resolvePath(inputPath);
212
+ const result = await runShell(
213
+ [
214
+ `if [ ! -e ${shellQuote(
215
+ target,
216
+ )} ]; then echo "__CLINE_LS_NOT_FOUND__"; exit 2; fi`,
217
+ `if [ ! -d ${shellQuote(
218
+ target,
219
+ )} ]; then echo "__CLINE_LS_NOT_DIR__"; exit 3; fi`,
220
+ `cd ${shellQuote(target)}`,
221
+ 'ls -1Ap',
222
+ ].join('; '),
223
+ );
224
+
225
+ const output = result.output.trim();
226
+ if (output.includes('__CLINE_LS_NOT_FOUND__')) {
227
+ throw new Error(`Path not found: ${inputPath}`);
228
+ }
229
+ if (output.includes('__CLINE_LS_NOT_DIR__')) {
230
+ throw new Error(`Not a directory: ${inputPath}`);
231
+ }
232
+
233
+ return output
234
+ .split('\n')
235
+ .filter(Boolean)
236
+ .map(line => line.replace(/[*=@|]$/, ''))
237
+ .sort((left, right) =>
238
+ left.toLowerCase().localeCompare(right.toLowerCase()),
239
+ )
240
+ .slice(0, limit);
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,139 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import {
4
+ safeParseJSON,
5
+ type Experimental_SandboxSession,
6
+ } from '@ai-sdk/provider-utils';
7
+ import type { AgentMessage } from '@cline/agents';
8
+ import { z } from 'zod/v4';
9
+
10
+ const CLINE_HISTORY_FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
11
+
12
+ export function safeClineHistoryFileName(historyFileName: string): string {
13
+ if (!CLINE_HISTORY_FILE_NAME_PATTERN.test(historyFileName)) {
14
+ throw new Error(`Invalid Cline history file name: ${historyFileName}`);
15
+ }
16
+ return historyFileName;
17
+ }
18
+
19
+ const clineHistoryFileNameSchema = z
20
+ .string()
21
+ .refine(
22
+ historyFileName => CLINE_HISTORY_FILE_NAME_PATTERN.test(historyFileName),
23
+ 'Cline historyFileName must be a safe .json basename.',
24
+ );
25
+
26
+ /**
27
+ * Schema for the adapter-specific portion of lifecycle state `data` produced
28
+ * by the Cline harness's resumable lifecycle methods. Carries the basename of
29
+ * the serialized conversation history. The actual history bytes live in a
30
+ * private, session-scoped directory under sandbox HOME so they survive
31
+ * cross-process resume without appearing in the agent workspace.
32
+ */
33
+ export const clineResumeStateSchema = z.looseObject({
34
+ historyFileName: clineHistoryFileNameSchema.optional(),
35
+ });
36
+
37
+ export type ClineResumeStateData = z.infer<typeof clineResumeStateSchema>;
38
+
39
+ export const CLINE_DEFAULT_HISTORY_FILE_NAME = 'history.json';
40
+
41
+ export function resolveClinePrivateSessionDirectory(input: {
42
+ readonly sandboxHomeDir: string;
43
+ readonly sessionWorkDir: string;
44
+ readonly sessionId: string;
45
+ }): string {
46
+ const sessionKey = createHash('sha256').update(input.sessionId).digest('hex');
47
+ const privateSessionDir = path.posix.join(
48
+ input.sandboxHomeDir,
49
+ '.ai-sdk',
50
+ 'harness-cline',
51
+ sessionKey,
52
+ );
53
+ const relativePath = path.posix.relative(
54
+ input.sessionWorkDir,
55
+ privateSessionDir,
56
+ );
57
+ if (
58
+ relativePath === '' ||
59
+ (!relativePath.startsWith('../') && !path.posix.isAbsolute(relativePath))
60
+ ) {
61
+ throw new Error(
62
+ `Cline private session directory ${JSON.stringify(privateSessionDir)} must be outside sessionWorkDir ${JSON.stringify(input.sessionWorkDir)}.`,
63
+ );
64
+ }
65
+ return privateSessionDir;
66
+ }
67
+
68
+ function resolveContainedSandboxPath(input: {
69
+ readonly privateSessionDir: string;
70
+ readonly historyFileName: string;
71
+ }): string {
72
+ const historyDir = path.posix.resolve(input.privateSessionDir);
73
+ const filePath = path.posix.resolve(
74
+ historyDir,
75
+ safeClineHistoryFileName(input.historyFileName),
76
+ );
77
+ const relativePath = path.posix.relative(historyDir, filePath);
78
+ if (
79
+ relativePath === '' ||
80
+ relativePath.startsWith('..') ||
81
+ path.posix.isAbsolute(relativePath)
82
+ ) {
83
+ throw new Error(
84
+ `Invalid Cline history file name: ${input.historyFileName}`,
85
+ );
86
+ }
87
+ return filePath;
88
+ }
89
+
90
+ /**
91
+ * Persist the runtime's conversation history into private sandbox state so a
92
+ * future process can resume the session after
93
+ * `HarnessV1SandboxProvider.resume?.({ sessionId })` reattaches the sandbox.
94
+ */
95
+ export async function persistHistoryToSandbox(args: {
96
+ readonly sandbox: Experimental_SandboxSession;
97
+ readonly privateSessionDir: string;
98
+ readonly historyFileName: string;
99
+ readonly messages: readonly AgentMessage[];
100
+ readonly abortSignal?: AbortSignal;
101
+ }): Promise<void> {
102
+ const remotePath = resolveContainedSandboxPath({
103
+ privateSessionDir: args.privateSessionDir,
104
+ historyFileName: args.historyFileName,
105
+ });
106
+ // `writeTextFile` creates parent directories recursively per the
107
+ // SandboxSession contract.
108
+ await args.sandbox.writeTextFile({
109
+ path: remotePath,
110
+ content: JSON.stringify(args.messages),
111
+ ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Pull a previously persisted conversation history from the sandbox. Returns
117
+ * `undefined` when the file is missing or unparsable — resume then falls back
118
+ * to a fresh conversation rather than failing `doStart`.
119
+ */
120
+ export async function pullHistoryFromSandbox(args: {
121
+ readonly sandbox: Experimental_SandboxSession;
122
+ readonly privateSessionDir: string;
123
+ readonly historyFileName: string;
124
+ readonly abortSignal?: AbortSignal;
125
+ }): Promise<AgentMessage[] | undefined> {
126
+ const remotePath = resolveContainedSandboxPath({
127
+ privateSessionDir: args.privateSessionDir,
128
+ historyFileName: args.historyFileName,
129
+ });
130
+ const content = await args.sandbox.readTextFile({
131
+ path: remotePath,
132
+ ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
133
+ });
134
+ if (content == null) return undefined;
135
+ const parsed = await safeParseJSON({ text: content });
136
+ return parsed.success && Array.isArray(parsed.value)
137
+ ? (parsed.value as unknown as AgentMessage[])
138
+ : undefined;
139
+ }