@clinebot/core 0.0.6 → 0.0.7

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.
@@ -2,7 +2,11 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { afterEach, describe, expect, it } from "vitest";
5
- import { migrateLegacyProviderSettings } from "./provider-settings-legacy-migration";
5
+ import {
6
+ type LegacyClineUserInfo,
7
+ migrateLegacyProviderSettings,
8
+ resolveLegacyClineAuth,
9
+ } from "./provider-settings-legacy-migration";
6
10
  import { ProviderSettingsManager } from "./provider-settings-manager";
7
11
 
8
12
  describe("migrateLegacyProviderSettings", () => {
@@ -173,3 +177,131 @@ describe("migrateLegacyProviderSettings", () => {
173
177
  );
174
178
  });
175
179
  });
180
+
181
+ // =============================================================================
182
+ // resolveLegacyClineAuth – pure in-memory tests
183
+ // =============================================================================
184
+
185
+ /** Builds a realistic LegacyClineUserInfo JSON string. */
186
+ function makeClineAccountJson(
187
+ overrides: Partial<LegacyClineUserInfo> & { userId?: string } = {},
188
+ ): string {
189
+ return JSON.stringify({
190
+ idToken: overrides.idToken ?? "id-token-abc",
191
+ expiresAt: overrides.expiresAt ?? 1750000000000,
192
+ refreshToken: overrides.refreshToken ?? "refresh-token-xyz",
193
+ userInfo: overrides.userInfo ?? {
194
+ id: overrides.userId ?? "user-42",
195
+ email: "test@example.com",
196
+ displayName: "Test User",
197
+ termsAcceptedAt: "2025-01-01T00:00:00Z",
198
+ clineBenchConsent: false,
199
+ createdAt: "2025-01-01T00:00:00Z",
200
+ updatedAt: "2025-01-01T00:00:00Z",
201
+ },
202
+ provider: overrides.provider ?? "google",
203
+ startedAt: overrides.startedAt ?? Date.now(),
204
+ } satisfies LegacyClineUserInfo);
205
+ }
206
+
207
+ describe("resolveLegacyClineAuth", () => {
208
+ it("extracts all auth fields from a complete legacy account JSON", () => {
209
+ const result = resolveLegacyClineAuth(
210
+ makeClineAccountJson({
211
+ idToken: "my-id-token",
212
+ expiresAt: 1750000000000,
213
+ refreshToken: "my-refresh",
214
+ userId: "user-123",
215
+ }),
216
+ );
217
+
218
+ expect(result).toEqual({
219
+ accessToken: "my-id-token",
220
+ refreshToken: "my-refresh",
221
+ expiresAt: 1750000000000,
222
+ accountId: "user-123",
223
+ });
224
+ });
225
+
226
+ it("maps idToken to accessToken", () => {
227
+ const result = resolveLegacyClineAuth(
228
+ makeClineAccountJson({ idToken: "tok-abc" }),
229
+ );
230
+ expect(result?.accessToken).toBe("tok-abc");
231
+ });
232
+
233
+ it("preserves expiresAt as a number", () => {
234
+ const result = resolveLegacyClineAuth(
235
+ makeClineAccountJson({ expiresAt: 9999999999999 }),
236
+ );
237
+ expect(result?.expiresAt).toBe(9999999999999);
238
+ expect(typeof result?.expiresAt).toBe("number");
239
+ });
240
+
241
+ it("maps userInfo.id to accountId", () => {
242
+ const result = resolveLegacyClineAuth(
243
+ makeClineAccountJson({ userId: "uid-xyz" }),
244
+ );
245
+ expect(result?.accountId).toBe("uid-xyz");
246
+ });
247
+
248
+ it("returns undefined accountId when userInfo is missing entirely", () => {
249
+ const raw = JSON.stringify({
250
+ idToken: "tok",
251
+ expiresAt: 1000,
252
+ refreshToken: "ref",
253
+ provider: "google",
254
+ startedAt: 1,
255
+ });
256
+
257
+ const result = resolveLegacyClineAuth(raw);
258
+ expect(result).toBeDefined();
259
+ expect(result?.accessToken).toBe("tok");
260
+ expect(result?.accountId).toBeUndefined();
261
+ });
262
+
263
+ it("returns undefined accountId when userInfo.id is missing", () => {
264
+ const raw = JSON.stringify({
265
+ idToken: "tok",
266
+ expiresAt: 1000,
267
+ refreshToken: "ref",
268
+ userInfo: {
269
+ email: "x@y.com",
270
+ displayName: "X",
271
+ termsAcceptedAt: "2025-01-01T00:00:00Z",
272
+ clineBenchConsent: false,
273
+ createdAt: "2025-01-01T00:00:00Z",
274
+ updatedAt: "2025-01-01T00:00:00Z",
275
+ },
276
+ provider: "google",
277
+ startedAt: 1,
278
+ });
279
+
280
+ const result = resolveLegacyClineAuth(raw);
281
+ expect(result).toBeDefined();
282
+ expect(result?.accountId).toBeUndefined();
283
+ });
284
+
285
+ it("returns undefined for invalid json", () => {
286
+ expect(resolveLegacyClineAuth(undefined)).toBeUndefined();
287
+ expect(resolveLegacyClineAuth("")).toBeUndefined();
288
+ expect(resolveLegacyClineAuth(" \n\t ")).toBeUndefined();
289
+ expect(resolveLegacyClineAuth("not-json{{{")).toBeUndefined();
290
+ expect(resolveLegacyClineAuth("null")).toBeUndefined();
291
+ });
292
+
293
+ it("returns undefined fields when idToken/refreshToken are missing from JSON", () => {
294
+ const raw = JSON.stringify({
295
+ userInfo: { id: "uid" },
296
+ provider: "google",
297
+ startedAt: 1,
298
+ });
299
+
300
+ const result = resolveLegacyClineAuth(raw);
301
+ expect(result).toBeDefined();
302
+ expect(result?.accessToken).toBeUndefined();
303
+ expect(result?.refreshToken).toBeUndefined();
304
+ expect(result?.expiresAt).toBeUndefined();
305
+ expect(result?.accountId).toBe("uid");
306
+ });
307
+ });
@@ -162,6 +162,53 @@ export interface MigrateLegacyProviderSettingsResult {
162
162
  lastUsedProvider?: string;
163
163
  }
164
164
 
165
+ export type LegacyClineUserInfo = {
166
+ idToken: string;
167
+ expiresAt: number;
168
+ refreshToken: string;
169
+ userInfo: {
170
+ id: string;
171
+ email: string;
172
+ displayName: string;
173
+ termsAcceptedAt: string;
174
+ clineBenchConsent: boolean;
175
+ createdAt: string;
176
+ updatedAt: string;
177
+ };
178
+ provider: string;
179
+ startedAt: number;
180
+ };
181
+
182
+ /**
183
+ * Resolves legacy Cline account auth data from the raw `cline:clineAccountId`
184
+ * secret string into the auth fields used by `ProviderSettings`.
185
+ *
186
+ * Returns `undefined` when the input is missing, empty, whitespace-only, or
187
+ * unparseable JSON.
188
+ */
189
+ export function resolveLegacyClineAuth(
190
+ rawAccountData: string | undefined,
191
+ ): ProviderSettings["auth"] | undefined {
192
+ const trimmed = rawAccountData?.trim();
193
+ if (!trimmed) {
194
+ return undefined;
195
+ }
196
+ try {
197
+ const data = JSON.parse(trimmed) as LegacyClineUserInfo;
198
+ if (!data) {
199
+ return undefined;
200
+ }
201
+ return {
202
+ accessToken: data.idToken,
203
+ refreshToken: data.refreshToken,
204
+ expiresAt: data.expiresAt,
205
+ accountId: data.userInfo?.id,
206
+ };
207
+ } catch {
208
+ return undefined;
209
+ }
210
+ }
211
+
165
212
  function trimNonEmpty(value: string | undefined): string | undefined {
166
213
  const trimmed = value?.trim();
167
214
  return trimmed ? trimmed : undefined;
@@ -400,14 +447,19 @@ function buildLegacyProviderSettings(
400
447
  Object.assign(providerSpecific, resolveLegacyCodexAuth(legacySecrets));
401
448
  }
402
449
  if (providerId === "cline") {
403
- const accountId = trimNonEmpty(
404
- legacySecrets["cline:clineAccountId"] ?? legacySecrets.clineAccountId,
405
- );
406
- if (accountId) {
407
- providerSpecific.auth = {
408
- ...(providerSpecific.auth ?? {}),
409
- accountId,
410
- };
450
+ try {
451
+ const legacyAuthString = trimNonEmpty(
452
+ legacySecrets["cline:clineAccountId"],
453
+ );
454
+
455
+ if (legacyAuthString) {
456
+ providerSpecific.auth = {
457
+ ...(providerSpecific.auth ?? {}),
458
+ ...resolveLegacyClineAuth(legacyAuthString),
459
+ };
460
+ }
461
+ } catch {
462
+ // Failed to parse stored cline auth data
411
463
  }
412
464
  }
413
465
  if (providerId === "openai" && legacyGlobalState.openAiHeaders) {
@@ -261,7 +261,7 @@ describe("zod schema conversion", () => {
261
261
  });
262
262
 
263
263
  describe("default editor tool", () => {
264
- it("accepts null for unused optional fields on str_replace", async () => {
264
+ it("accepts replacement edits without insert fields", async () => {
265
265
  const execute = vi.fn(async () => "patched");
266
266
  const tools = createDefaultTools({
267
267
  executors: {
@@ -284,12 +284,9 @@ describe("default editor tool", () => {
284
284
 
285
285
  const result = await editorTool.execute(
286
286
  {
287
- command: "str_replace",
288
287
  path: "/tmp/example.ts",
289
- old_str: "before",
290
- new_str: "after",
291
- file_text: null,
292
- insert_line: null,
288
+ old_text: "before",
289
+ new_text: "after",
293
290
  },
294
291
  {
295
292
  agentId: "agent-1",
@@ -299,18 +296,15 @@ describe("default editor tool", () => {
299
296
  );
300
297
 
301
298
  expect(result).toEqual({
302
- query: "str_replace:/tmp/example.ts",
299
+ query: "edit:/tmp/example.ts",
303
300
  result: "patched",
304
301
  success: true,
305
302
  });
306
303
  expect(execute).toHaveBeenCalledWith(
307
304
  expect.objectContaining({
308
- command: "str_replace",
309
305
  path: "/tmp/example.ts",
310
- old_str: "before",
311
- new_str: "after",
312
- file_text: null,
313
- insert_line: null,
306
+ old_text: "before",
307
+ new_text: "after",
314
308
  }),
315
309
  process.cwd(),
316
310
  expect.objectContaining({
@@ -321,7 +315,7 @@ describe("default editor tool", () => {
321
315
  );
322
316
  });
323
317
 
324
- it("still rejects null for required insert fields", async () => {
318
+ it("allows edit without old_text so missing files can be created", async () => {
325
319
  const execute = vi.fn(async () => "patched");
326
320
  const tools = createDefaultTools({
327
321
  executors: {
@@ -342,21 +336,80 @@ describe("default editor tool", () => {
342
336
  throw new Error("Expected editor tool to be defined.");
343
337
  }
344
338
 
345
- await expect(
346
- editorTool.execute(
347
- {
348
- command: "insert",
349
- path: "/tmp/example.ts",
350
- new_str: "after",
351
- insert_line: null,
352
- },
353
- {
354
- agentId: "agent-1",
355
- conversationId: "conv-1",
356
- iteration: 1,
357
- },
358
- ),
359
- ).rejects.toThrow(/insert_line is required for command=insert/);
360
- expect(execute).not.toHaveBeenCalled();
339
+ const result = await editorTool.execute(
340
+ {
341
+ path: "/tmp/example.ts",
342
+ new_text: "created",
343
+ },
344
+ {
345
+ agentId: "agent-1",
346
+ conversationId: "conv-1",
347
+ iteration: 1,
348
+ },
349
+ );
350
+
351
+ expect(result).toEqual({
352
+ query: "edit:/tmp/example.ts",
353
+ result: "patched",
354
+ success: true,
355
+ });
356
+ expect(execute).toHaveBeenCalledWith(
357
+ expect.objectContaining({
358
+ path: "/tmp/example.ts",
359
+ new_text: "created",
360
+ }),
361
+ process.cwd(),
362
+ expect.anything(),
363
+ );
364
+ });
365
+
366
+ it("treats insert_line as an insert operation", async () => {
367
+ const execute = vi.fn(async () => "patched");
368
+ const tools = createDefaultTools({
369
+ executors: {
370
+ editor: execute,
371
+ },
372
+ enableReadFiles: false,
373
+ enableSearch: false,
374
+ enableBash: false,
375
+ enableWebFetch: false,
376
+ enableSkills: false,
377
+ enableAskQuestion: false,
378
+ enableApplyPatch: false,
379
+ enableEditor: true,
380
+ });
381
+ const editorTool = tools.find((tool) => tool.name === "editor");
382
+ expect(editorTool).toBeDefined();
383
+ if (!editorTool) {
384
+ throw new Error("Expected editor tool to be defined.");
385
+ }
386
+
387
+ const result = await editorTool.execute(
388
+ {
389
+ path: "/tmp/example.ts",
390
+ new_text: "after",
391
+ insert_line: 3,
392
+ },
393
+ {
394
+ agentId: "agent-1",
395
+ conversationId: "conv-1",
396
+ iteration: 1,
397
+ },
398
+ );
399
+
400
+ expect(result).toEqual({
401
+ query: "insert:/tmp/example.ts",
402
+ result: "patched",
403
+ success: true,
404
+ });
405
+ expect(execute).toHaveBeenCalledWith(
406
+ expect.objectContaining({
407
+ path: "/tmp/example.ts",
408
+ new_text: "after",
409
+ insert_line: 3,
410
+ }),
411
+ process.cwd(),
412
+ expect.anything(),
413
+ );
361
414
  });
362
415
  });
@@ -432,14 +432,16 @@ export function createEditorTool(
432
432
  return createTool<EditFileInput, ToolOperationResult>({
433
433
  name: "editor",
434
434
  description:
435
- "Edit file using absolute path with create, string replacement, and line insert operations. " +
436
- "Supported commands: create, str_replace, insert.",
435
+ "An editor for controlled filesystem edits on the text file at the provided path. " +
436
+ "Provide `insert_line` to insert `new_text` at a specific line number. " +
437
+ "Otherwise, the tool replaces `old_text` with `new_text`, or creates the file with `new_text` if it does not exist.",
437
438
  inputSchema: zodToJsonSchema(EditFileInputSchema),
438
439
  timeoutMs,
439
440
  retryable: false, // Editing operations are stateful and should not auto-retry
440
441
  maxRetries: 0,
441
442
  execute: async (input, context) => {
442
443
  const validatedInput = validateWithZod(EditFileInputSchema, input);
444
+ const operation = validatedInput.insert_line == null ? "edit" : "insert";
443
445
 
444
446
  try {
445
447
  const result = await withTimeout(
@@ -449,14 +451,14 @@ export function createEditorTool(
449
451
  );
450
452
 
451
453
  return {
452
- query: `${validatedInput.command}:${validatedInput.path}`,
454
+ query: `${operation}:${validatedInput.path}`,
453
455
  result,
454
456
  success: true,
455
457
  };
456
458
  } catch (error) {
457
459
  const msg = formatError(error);
458
460
  return {
459
- query: `${validatedInput.command}:${validatedInput.path}`,
461
+ query: `${operation}:${validatedInput.path}`,
460
462
  result: "",
461
463
  error: `Editor operation failed: ${msg}`,
462
464
  success: false,
@@ -0,0 +1,35 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { createEditorExecutor } from "./editor.js";
6
+
7
+ describe("createEditorExecutor", () => {
8
+ it("creates a missing file when edit is used", async () => {
9
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-editor-"));
10
+ const filePath = path.join(dir, "example.txt");
11
+
12
+ try {
13
+ const editor = createEditorExecutor();
14
+ const result = await editor(
15
+ {
16
+ path: filePath,
17
+ new_text: "created with edit",
18
+ },
19
+ dir,
20
+ {
21
+ agentId: "agent-1",
22
+ conversationId: "conv-1",
23
+ iteration: 1,
24
+ },
25
+ );
26
+
27
+ expect(result).toBe(`File created successfully at: ${filePath}`);
28
+ await expect(fs.readFile(filePath, "utf-8")).resolves.toBe(
29
+ "created with edit",
30
+ );
31
+ } finally {
32
+ await fs.rm(dir, { recursive: true, force: true });
33
+ }
34
+ });
35
+ });
@@ -113,6 +113,15 @@ async function createFile(
113
113
  return `File created successfully at: ${filePath}`;
114
114
  }
115
115
 
116
+ async function fileExists(filePath: string): Promise<boolean> {
117
+ try {
118
+ await fs.access(filePath);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
116
125
  async function replaceInFile(
117
126
  filePath: string,
118
127
  oldStr: string,
@@ -181,52 +190,30 @@ export function createEditorExecutor(
181
190
  ): Promise<string> => {
182
191
  const filePath = resolveFilePath(cwd, input.path, restrictToCwd);
183
192
 
184
- switch (input.command) {
185
- case "create":
186
- if (input.file_text == null) {
187
- throw new Error(
188
- "Parameter `file_text` is required for command: create",
189
- );
190
- }
191
- return createFile(filePath, input.file_text, encoding);
192
-
193
- case "str_replace":
194
- if (input.old_str == null) {
195
- throw new Error(
196
- "Parameter `old_str` is required for command: str_replace",
197
- );
198
- }
199
- return replaceInFile(
200
- filePath,
201
- input.old_str,
202
- input.new_str,
203
- encoding,
204
- maxDiffLines,
205
- );
206
-
207
- case "insert":
208
- if (input.insert_line == null) {
209
- throw new Error(
210
- "Parameter `insert_line` is required for insert command.",
211
- );
212
- }
213
- if (input.new_str == null) {
214
- throw new Error(
215
- "Parameter `new_str` is required for insert command.",
216
- );
217
- }
218
- return insertInFile(
219
- filePath,
220
- input.insert_line, // One-based index
221
- input.new_str,
222
- encoding,
223
- );
224
-
225
- default:
226
- throw new Error(
227
- `Unrecognized command ${(input as { command: string }).command}. ` +
228
- "Allowed commands are: create, str_replace, insert",
229
- );
193
+ if (input.insert_line != null) {
194
+ return insertInFile(
195
+ filePath,
196
+ input.insert_line, // One-based index
197
+ input.new_text,
198
+ encoding,
199
+ );
200
+ }
201
+
202
+ if (!(await fileExists(filePath))) {
203
+ return createFile(filePath, input.new_text, encoding);
204
+ }
205
+ if (input.old_text == null) {
206
+ throw new Error(
207
+ "Parameter `old_text` is required when editing an existing file without `insert_line`",
208
+ );
230
209
  }
210
+
211
+ return replaceInFile(
212
+ filePath,
213
+ input.old_text,
214
+ input.new_text,
215
+ encoding,
216
+ maxDiffLines,
217
+ );
231
218
  };
232
219
  }
@@ -52,14 +52,16 @@ export const SearchCodebaseUnionInputSchema = z.union([
52
52
  z.string(),
53
53
  ]);
54
54
 
55
- const CommandInputSchema = z.string();
55
+ const CommandInputSchema = z
56
+ .string()
57
+ .describe("The non-interactive shell command to execute");
56
58
  /**
57
59
  * Schema for run_commands tool input
58
60
  */
59
61
  export const RunCommandsInputSchema = z.object({
60
62
  commands: z
61
63
  .array(CommandInputSchema)
62
- .describe("Array of shell commands to execute."),
64
+ .describe("Array of shell commands to execute"),
63
65
  });
64
66
 
65
67
  /**
@@ -67,8 +69,8 @@ export const RunCommandsInputSchema = z.object({
67
69
  */
68
70
  export const RunCommandsInputUnionSchema = z.union([
69
71
  RunCommandsInputSchema,
70
- z.array(CommandInputSchema),
71
- CommandInputSchema,
72
+ z.array(z.string()),
73
+ z.string(),
72
74
  ]);
73
75
 
74
76
  /**
@@ -93,46 +95,34 @@ export const FetchWebContentInputSchema = z.object({
93
95
  */
94
96
  export const EditFileInputSchema = z
95
97
  .object({
96
- command: z
97
- .enum(["create", "str_replace", "insert"])
98
- .describe("Editor command to execute: create, str_replace, insert"),
99
- path: z.string().min(1).describe("Absolute file path"),
100
- file_text: z
98
+ path: z
101
99
  .string()
102
- .nullish()
103
- .describe("Full file content required for 'create' command"),
104
- old_str: z
100
+ .min(1)
101
+ .describe("The absolute file path for the action to be performed on"),
102
+ old_text: z
105
103
  .string()
106
- .nullish()
104
+ .nullable()
105
+ .optional()
107
106
  .describe(
108
- "Exact text to replace (must match exactly once) for 'str_replace' command",
107
+ "Exact text to replace (must match exactly once). Omit this when creating a missing file or inserting via insert_line.",
109
108
  ),
110
- new_str: z
109
+ new_text: z
111
110
  .string()
112
- .nullish()
113
- .describe("Replacement text for 'str_replace' or 'insert' commands"),
111
+ .describe(
112
+ "The new content to write when creating a missing file, the replacement text for edits, or the inserted text when insert_line is provided",
113
+ ),
114
114
  insert_line: z
115
115
  .number()
116
116
  .int()
117
- .nullish()
118
- .describe("Optional one-based line index for 'insert' command"),
119
- })
120
- .refine((v) => v.command !== "create" || v.file_text != null, {
121
- path: ["file_text"],
122
- message: "file_text is required for command=create",
123
- })
124
- .refine((v) => v.command !== "str_replace" || v.old_str != null, {
125
- path: ["old_str"],
126
- message: "old_str is required for command=str_replace",
127
- })
128
- .refine((v) => v.command !== "insert" || v.insert_line != null, {
129
- path: ["insert_line"],
130
- message: "insert_line is required for command=insert",
117
+ .nullable()
118
+ .optional()
119
+ .describe(
120
+ "Optional one-based line index. When provided, the tool inserts new_text at that line instead of performing a replacement edit.",
121
+ ),
131
122
  })
132
- .refine((v) => v.command !== "insert" || v.new_str != null, {
133
- path: ["new_str"],
134
- message: "new_str is required for command=insert",
135
- });
123
+ .describe(
124
+ "Edit a text file by replacing old_text with new_text, create the file with new_text if it does not exist, or insert new_text at insert_line when insert_line is provided. IMPORTANT: large edits can time out, so use small chunks and multiple calls when possible.",
125
+ );
136
126
 
137
127
  /**
138
128
  * Schema for apply_patch tool input
@@ -13,7 +13,12 @@ export interface SessionEndedEvent {
13
13
 
14
14
  export interface SessionToolEvent {
15
15
  sessionId: string;
16
- hookEventName: "tool_call" | "tool_result" | "agent_end" | "session_shutdown";
16
+ hookEventName:
17
+ | "tool_call"
18
+ | "tool_result"
19
+ | "agent_end"
20
+ | "agent_error"
21
+ | "session_shutdown";
17
22
  agentId?: string;
18
23
  conversationId?: string;
19
24
  parentAgentId?: string;