@ai-sdk/harness-cline 1.0.19 → 1.0.21

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness-cline",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,11 +26,11 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@ai-sdk/harness": "1.0.92",
30
- "@ai-sdk/provider": "4.0.8",
31
- "@ai-sdk/provider-utils": "5.0.32",
32
- "@cline/agents": "^0.0.72",
33
- "@cline/core": "^0.0.72"
29
+ "@ai-sdk/harness": "1.0.94",
30
+ "@ai-sdk/provider": "4.0.9",
31
+ "@ai-sdk/provider-utils": "5.0.34",
32
+ "@cline/agents": "^0.0.79",
33
+ "@cline/core": "^0.0.79"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "zod": "^3.25.76 || ^4.1.8"
@@ -37,6 +37,8 @@ export type ClineHarnessSettings = {
37
37
  /**
38
38
  * Model id for the configured provider. When omitted, Cline selects the
39
39
  * provider's default model.
40
+ *
41
+ * @deprecated Use `model` on `HarnessAgent` instead.
40
42
  */
41
43
  readonly modelId?: string;
42
44
  /**
@@ -140,6 +142,18 @@ const CLINE_BUILTIN_TOOLS = {
140
142
  nativeName: 'ls',
141
143
  toolUseKind: 'readonly',
142
144
  } as HarnessV1BuiltinTool,
145
+ skills: {
146
+ ...tool({
147
+ description: 'Execute a configured skill by name.',
148
+ inputSchema: z.object({
149
+ skill: z.string(),
150
+ args: z.string().optional(),
151
+ }),
152
+ outputSchema: z.string(),
153
+ }),
154
+ nativeName: 'skills',
155
+ toolUseKind: 'readonly',
156
+ } as HarnessV1BuiltinTool,
143
157
  } as const satisfies Record<string, HarnessV1BuiltinTool<any, any>>;
144
158
 
145
159
  export function createCline(
@@ -163,14 +177,13 @@ export function createCline(
163
177
  sessionId: startOpts.sessionId,
164
178
  sandboxSession: startOpts.sandboxSession,
165
179
  sessionWorkDir: startOpts.sessionWorkDir,
166
- skills: startOpts.skills ?? [],
167
180
  settings: {
168
181
  authEnv,
169
182
  isAuthenticationEnvironmentOverride:
170
183
  isHarnessAuthenticationEnvironment(settings.auth),
171
184
  ...(settings.mcpServers ? { mcpServers: settings.mcpServers } : {}),
172
185
  ...(settings.providerId ? { providerId: settings.providerId } : {}),
173
- ...(settings.modelId ? { modelId: settings.modelId } : {}),
186
+ ...(settings.modelId == null ? {} : { modelId: settings.modelId }),
174
187
  ...(settings.apiKey ? { apiKey: settings.apiKey } : {}),
175
188
  ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}),
176
189
  ...(settings.headers ? { headers: settings.headers } : {}),
@@ -14,7 +14,6 @@ export interface ClineRemoteOpsOptions {
14
14
  readonly sandbox: Experimental_SandboxSession;
15
15
  /** Absolute sandbox path of the session's working directory. */
16
16
  readonly workDir: string;
17
- readonly readableRoots?: ReadonlyArray<{ readonly sandboxDir: string }>;
18
17
  }
19
18
 
20
19
  export interface ClineRemoteOps {
@@ -50,9 +49,6 @@ export function createClineRemoteOps(
50
49
  ): ClineRemoteOps {
51
50
  const { sandbox, workDir } = options;
52
51
  const normalizedWorkDir = path.posix.normalize(workDir);
53
- const readableRoots =
54
- options.readableRoots?.map(root => path.posix.normalize(root.sandboxDir)) ??
55
- [];
56
52
 
57
53
  const isInsidePath = ({
58
54
  parent,
@@ -78,19 +74,6 @@ export function createClineRemoteOps(
78
74
  return normalized;
79
75
  };
80
76
 
81
- const assertReadablePath = (inputPath: string): string => {
82
- const normalized = path.posix.normalize(inputPath);
83
- if (
84
- !isInsidePath({ parent: normalizedWorkDir, candidate: normalized }) &&
85
- !readableRoots.some(parent =>
86
- isInsidePath({ parent, candidate: normalized }),
87
- )
88
- ) {
89
- throw new Error(`Cline path escapes the readable roots: ${inputPath}`);
90
- }
91
- return normalized;
92
- };
93
-
94
77
  const resolvePath = (inputPath: string): string => {
95
78
  const resolved = path.posix.isAbsolute(inputPath)
96
79
  ? path.posix.normalize(inputPath)
@@ -98,13 +81,6 @@ export function createClineRemoteOps(
98
81
  return assertWorkspacePath(resolved);
99
82
  };
100
83
 
101
- const resolveReadablePath = (inputPath: string): string => {
102
- if (!path.posix.isAbsolute(inputPath)) {
103
- return resolvePath(inputPath);
104
- }
105
- return assertReadablePath(inputPath);
106
- };
107
-
108
84
  const runShell = async ({
109
85
  command,
110
86
  signal,
@@ -172,7 +148,7 @@ export function createClineRemoteOps(
172
148
  inputPath: string;
173
149
  missingMessage?: string;
174
150
  }): Promise<string> =>
175
- assertReadablePath(
151
+ assertWorkspacePath(
176
152
  await resolveExistingSandboxPath({
177
153
  remotePath,
178
154
  inputPath,
@@ -216,7 +192,7 @@ export function createClineRemoteOps(
216
192
  };
217
193
 
218
194
  const readFile = async (inputPath: string): Promise<string> => {
219
- const remotePath = resolveReadablePath(inputPath);
195
+ const remotePath = resolvePath(inputPath);
220
196
  const resolved = await resolveReadableSandboxPath({
221
197
  remotePath,
222
198
  inputPath,
@@ -305,7 +281,7 @@ export function createClineRemoteOps(
305
281
 
306
282
  async grep(pattern, input = {}) {
307
283
  const inputPath = input.path ?? '.';
308
- const remotePath = resolveReadablePath(inputPath);
284
+ const remotePath = resolvePath(inputPath);
309
285
  const target = await resolveReadableSandboxPath({
310
286
  remotePath,
311
287
  inputPath,
@@ -341,7 +317,7 @@ export function createClineRemoteOps(
341
317
  },
342
318
 
343
319
  async glob(pattern, inputPath = '.', limit = 1_000) {
344
- const remotePath = resolveReadablePath(inputPath);
320
+ const remotePath = resolvePath(inputPath);
345
321
  const target = await resolveReadableSandboxPath({
346
322
  remotePath,
347
323
  inputPath,
@@ -382,7 +358,7 @@ export function createClineRemoteOps(
382
358
  },
383
359
 
384
360
  async ls(inputPath = '.', limit = 500) {
385
- const remotePath = resolveReadablePath(inputPath);
361
+ const remotePath = resolvePath(inputPath);
386
362
  const target = await resolveReadableSandboxPath({
387
363
  remotePath,
388
364
  inputPath,
@@ -35,7 +35,10 @@ import {
35
35
  resolveClinePrivateSessionDirectory,
36
36
  safeClineHistoryFileName,
37
37
  } from './cline-resume-state';
38
- import { renderSkillsPromptSection, writeClineSkills } from './cline-skills';
38
+ import {
39
+ createClineSkillsRuntime,
40
+ type ClineSkillsRuntime,
41
+ } from './cline-skills';
39
42
  import {
40
43
  buildBuiltinAgentTools,
41
44
  buildUserAgentTools,
@@ -97,7 +100,6 @@ export interface CreateClineSessionInput {
97
100
  readonly sessionId: string;
98
101
  readonly sandboxSession: HarnessV1NetworkSandboxSession | SandboxSession;
99
102
  readonly sessionWorkDir: string;
100
- readonly skills: ReadonlyArray<HarnessV1Skill>;
101
103
  readonly settings: ClineSessionSettings;
102
104
  readonly clientApp: string;
103
105
  readonly isResume: boolean;
@@ -154,17 +156,12 @@ function clineBuiltinToolRequiresApproval(input: {
154
156
  function buildSystemPrompt(input: {
155
157
  sessionWorkDir: string;
156
158
  sandboxDescription: string;
157
- skillsSection?: string;
158
159
  }): string {
159
- const sections = [
160
+ return [
160
161
  'You are Cline, an autonomous coding agent operating inside a sandboxed workspace.',
161
162
  `## Workspace\n\nThe workspace root is \`${input.sessionWorkDir}\`. All relative file paths and shell commands resolve against it. Use the provided tools (read, write, edit, bash, grep, glob, ls) to inspect and modify the workspace.`,
162
163
  `## Sandbox\n\n${input.sandboxDescription}`,
163
- ];
164
- if (input.skillsSection) {
165
- sections.push(input.skillsSection);
166
- }
167
- return sections.join('\n\n');
164
+ ].join('\n\n');
168
165
  }
169
166
 
170
167
  function toClineBackendProviderBaseUrl(baseUrl: string): string {
@@ -179,9 +176,11 @@ function toAiGatewayProviderBaseUrl(baseUrl: string): string {
179
176
  function createClineAgentModel({
180
177
  settings,
181
178
  clientApp,
179
+ modelId,
182
180
  }: {
183
181
  settings: ClineSessionSettings;
184
182
  clientApp: string;
183
+ modelId: string | undefined;
185
184
  }): { model: AgentModel; providerId: string } {
186
185
  const gatewayBaseUrl = settings.authEnv.AI_GATEWAY_BASE_URL;
187
186
  const isAiGateway = gatewayBaseUrl != null;
@@ -218,7 +217,7 @@ function createClineAgentModel({
218
217
  });
219
218
  const modelSelection = {
220
219
  providerId,
221
- ...(settings.modelId ? { modelId: settings.modelId } : {}),
220
+ ...(modelId ? { modelId } : {}),
222
221
  };
223
222
  const modelOptions =
224
223
  settings.reasoningEffort === 'none'
@@ -271,32 +270,9 @@ export async function createClineSession(
271
270
  input.builtinToolFiltering,
272
271
  );
273
272
 
274
- // Materialize harness-provided skills into sandbox HOME (not the workspace)
275
- // and advertise them via a system prompt section.
276
- let skillsSection: string | undefined;
277
- let skillRootDir: string | undefined;
278
- if (input.skills.length > 0) {
279
- skillRootDir = await writeClineSkills({
280
- sandbox: toolSafeSandboxSession,
281
- sandboxHomeDir,
282
- skills: input.skills,
283
- ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
284
- });
285
- if (skillRootDir) {
286
- skillsSection = renderSkillsPromptSection(input.skills, skillRootDir);
287
- }
288
- }
289
-
290
273
  const ops = createClineRemoteOps({
291
274
  sandbox: toolSafeSandboxSession,
292
275
  workDir: input.sessionWorkDir,
293
- ...(skillRootDir ? { readableRoots: [{ sandboxDir: skillRootDir }] } : {}),
294
- });
295
-
296
- const baseSystemPrompt = buildSystemPrompt({
297
- sessionWorkDir: input.sessionWorkDir,
298
- sandboxDescription: toolSafeSandboxSession.description,
299
- ...(skillsSection ? { skillsSection } : {}),
300
276
  });
301
277
 
302
278
  // On resume: pull the persisted conversation history from the sandbox so
@@ -316,15 +292,17 @@ export async function createClineSession(
316
292
  const mcpRuntime = await createClineMcpRuntime({
317
293
  mcpServers: input.settings.mcpServers,
318
294
  });
319
- const { model, providerId } = createClineAgentModel({
295
+ let activeModelId = input.settings.modelId;
296
+ let agentModel = createClineAgentModel({
320
297
  settings: input.settings,
321
298
  clientApp: input.clientApp,
299
+ modelId: activeModelId,
322
300
  });
323
301
 
324
302
  // Per-session mutable state we hold across prompts.
325
303
  let agent: Agent | undefined;
326
304
  let unsubscribe: (() => void) | undefined;
327
- let lastToolsSignature: string | undefined;
305
+ let lastAgentConfigurationSignature: string | undefined;
328
306
  let agentHasRun = false;
329
307
  let stopped = false;
330
308
  /*
@@ -404,25 +382,32 @@ export async function createClineSession(
404
382
 
405
383
  function rebuildAgent(rebuildInput: {
406
384
  userTools: ReadonlyArray<HarnessV1ToolSpec>;
385
+ skillsRuntime: ClineSkillsRuntime;
407
386
  instructions?: string;
408
387
  responseFormat?: HarnessV1PromptTurnOptions['responseFormat'];
409
388
  }): void {
410
389
  unsubscribe?.();
411
390
  unsubscribe = undefined;
412
391
  agent = new Agent({
413
- model,
414
- ...(input.settings.modelId
392
+ model: agentModel.model,
393
+ ...(activeModelId
415
394
  ? {
416
395
  messageModelInfo: {
417
- id: input.settings.modelId,
418
- provider: providerId,
396
+ id: activeModelId,
397
+ provider: agentModel.providerId,
419
398
  },
420
399
  }
421
400
  : {}),
422
401
  sessionId: input.sessionId,
423
- systemPrompt: rebuildInput.instructions
424
- ? `${baseSystemPrompt}\n\n${rebuildInput.instructions}`
425
- : baseSystemPrompt,
402
+ systemPrompt: (() => {
403
+ const baseSystemPrompt = buildSystemPrompt({
404
+ sessionWorkDir: input.sessionWorkDir,
405
+ sandboxDescription: toolSafeSandboxSession.description,
406
+ });
407
+ return rebuildInput.instructions
408
+ ? `${baseSystemPrompt}\n\n${rebuildInput.instructions}`
409
+ : baseSystemPrompt;
410
+ })(),
426
411
  initialMessages: currentMessages,
427
412
  toolExecution: 'parallel',
428
413
  ...(input.settings.maxIterations !== undefined
@@ -430,6 +415,10 @@ export async function createClineSession(
430
415
  : {}),
431
416
  tools: [
432
417
  ...buildBuiltinAgentTools({ ops, activeNames: activeBuiltinNames }),
418
+ ...(rebuildInput.skillsRuntime.tool &&
419
+ activeBuiltinNames.includes('skills')
420
+ ? [rebuildInput.skillsRuntime.tool]
421
+ : []),
433
422
  ...mcpRuntime.tools,
434
423
  ...buildUserAgentTools({
435
424
  specs: rebuildInput.userTools,
@@ -572,19 +561,24 @@ export async function createClineSession(
572
561
  * Shared by `doPromptTurn` (a fresh user prompt) and `doContinueTurn`
573
562
  * (no prompt — the runtime continues its own thread after a rerun resume).
574
563
  */
575
- function runTurn(turnOpts: {
564
+ async function runTurn(turnOpts: {
576
565
  text: string | undefined;
566
+ model?: string;
567
+ skills: ReadonlyArray<HarnessV1Skill>;
577
568
  tools: ReadonlyArray<HarnessV1ToolSpec>;
578
569
  instructions?: string;
579
570
  emit: (part: HarnessV1StreamPart) => void;
580
571
  abortSignal?: AbortSignal;
581
572
  responseFormat?: HarnessV1PromptTurnOptions['responseFormat'];
582
- }): HarnessV1PromptControl {
573
+ }): Promise<HarnessV1PromptControl> {
583
574
  if (stopped) {
584
575
  throw new Error('Cline session has been stopped.');
585
576
  }
586
577
 
587
578
  const userTools = turnOpts.tools;
579
+ const skillsRuntime = createClineSkillsRuntime({
580
+ skills: turnOpts.skills,
581
+ });
588
582
  if (
589
583
  turnOpts.responseFormat?.type === 'json' &&
590
584
  turnOpts.responseFormat.schema == null
@@ -597,7 +591,7 @@ export async function createClineSession(
597
591
  }
598
592
  if (
599
593
  turnOpts.responseFormat?.type === 'json' &&
600
- providerId === 'openai-codex-cli'
594
+ agentModel.providerId === 'openai-codex-cli'
601
595
  ) {
602
596
  throw new HarnessCapabilityUnsupportedError({
603
597
  message:
@@ -606,26 +600,42 @@ export async function createClineSession(
606
600
  });
607
601
  }
608
602
 
603
+ if (turnOpts.model != null && turnOpts.model !== activeModelId) {
604
+ activeModelId = turnOpts.model;
605
+ agentModel = createClineAgentModel({
606
+ settings: input.settings,
607
+ clientApp: input.clientApp,
608
+ modelId: activeModelId,
609
+ });
610
+ }
611
+
609
612
  const signature = JSON.stringify({
610
- tools: userTools.map(t => t.name).sort(),
613
+ modelId: activeModelId,
614
+ tools: userTools,
611
615
  responseFormat: turnOpts.responseFormat,
616
+ instructions: turnOpts.instructions,
617
+ skills: skillsRuntime.signature,
612
618
  });
613
- if (agent == null || signature !== lastToolsSignature) {
619
+ if (agent == null || signature !== lastAgentConfigurationSignature) {
614
620
  currentMessages = agent?.snapshot().messages ?? currentMessages;
615
621
  rebuildAgent({
616
622
  userTools,
623
+ skillsRuntime,
617
624
  responseFormat: turnOpts.responseFormat,
618
625
  ...(turnOpts.instructions
619
626
  ? { instructions: turnOpts.instructions }
620
627
  : {}),
621
628
  });
622
- lastToolsSignature = signature;
629
+ lastAgentConfigurationSignature = signature;
623
630
  }
624
631
 
625
632
  currentEmit = turnOpts.emit;
633
+ const userToolNames = new Set(userTools.map(tool => tool.name));
626
634
  translatorState = createClineTranslatorState({
627
- builtinToolNames: activeBuiltinNames,
628
- hostToolNames: userTools.map(tool => tool.name),
635
+ builtinToolNames: activeBuiltinNames.filter(
636
+ name => !userToolNames.has(name),
637
+ ),
638
+ hostToolNames: userToolNames,
629
639
  ignoredToolNames:
630
640
  turnOpts.responseFormat?.type === 'json' ? ['structured_output'] : [],
631
641
  mcpToolNames: mcpRuntime.toolNames,
@@ -633,7 +643,7 @@ export async function createClineSession(
633
643
 
634
644
  turnOpts.emit({
635
645
  type: 'stream-start',
636
- ...(input.settings.modelId ? { modelId: input.settings.modelId } : {}),
646
+ ...(activeModelId ? { modelId: activeModelId } : {}),
637
647
  });
638
648
 
639
649
  acceptingUserMessages = true;
@@ -798,7 +808,6 @@ export async function createClineSession(
798
808
  const sessionImpl: HarnessV1Session = {
799
809
  sessionId: input.sessionId,
800
810
  isResume: input.isResume,
801
- ...(input.settings.modelId ? { modelId: input.settings.modelId } : {}),
802
811
 
803
812
  // The Cline runtime has no bridge to attach to and no in-sandbox event
804
813
  // log to replay; its only cross-process resume path is restoring the
@@ -809,6 +818,8 @@ export async function createClineSession(
809
818
  ): Promise<HarnessV1PromptControl> => {
810
819
  return runTurn({
811
820
  text: extractUserText(promptOpts.prompt),
821
+ ...(promptOpts.model ? { model: promptOpts.model } : {}),
822
+ skills: promptOpts.skills,
812
823
  tools: promptOpts.tools ?? [],
813
824
  ...(promptOpts.instructions
814
825
  ? { instructions: promptOpts.instructions }
@@ -843,6 +854,8 @@ export async function createClineSession(
843
854
  */
844
855
  return runTurn({
845
856
  text: undefined,
857
+ ...(continueOpts.model ? { model: continueOpts.model } : {}),
858
+ skills: continueOpts.skills,
846
859
  tools: continueOpts.tools ?? [],
847
860
  ...(continueOpts.instructions
848
861
  ? { instructions: continueOpts.instructions }
@@ -1,59 +1,184 @@
1
1
  import path from 'node:path';
2
2
  import type { HarnessV1Skill } from '@ai-sdk/harness';
3
- import { writeSkills } from '@ai-sdk/harness/utils';
4
- import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
3
+ import type { AgentTool } from '@cline/agents';
4
+ import { createDefaultTools, type ToolExecutors } from '@cline/core';
5
+
6
+ const CLINE_SKILL_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
7
+
8
+ type ProjectedClineSkill = {
9
+ readonly id: string;
10
+ readonly name: string;
11
+ readonly description: string;
12
+ readonly content: string;
13
+ readonly files: ReadonlyArray<{
14
+ readonly path: string;
15
+ readonly content: string;
16
+ }>;
17
+ };
18
+
19
+ export type ClineSkillsRuntime = {
20
+ readonly signature: string;
21
+ readonly tool?: AgentTool;
22
+ };
5
23
 
6
24
  /**
7
- * Materialize harness-provided skills into the sandbox HOME
8
- * (`~/.agents/skills/<name>/SKILL.md` plus any auxiliary files) and return
9
- * the root directory they were written to. The Cline runtime has no native
10
- * skill loader, so the session surfaces them to the model through a system
11
- * prompt section (see `renderSkillsPromptSection`) that points at these files.
25
+ * Build Cline's native skills tool around an immutable in-host projection of
26
+ * the harness skills. The low-level Agent does not discover skills itself, so
27
+ * the adapter provides the same tool contract that Cline's core orchestration
28
+ * layer uses without involving the sandbox filesystem.
12
29
  */
13
- export async function writeClineSkills({
14
- sandbox,
15
- sandboxHomeDir,
30
+ export function createClineSkillsRuntime({
16
31
  skills,
17
- abortSignal,
18
32
  }: {
19
- sandbox: Experimental_SandboxSession;
20
- sandboxHomeDir: string;
21
33
  skills: ReadonlyArray<HarnessV1Skill>;
22
- abortSignal?: AbortSignal;
23
- }): Promise<string | undefined> {
24
- if (skills.length === 0) return undefined;
25
- const skillRootDir = path.posix.join(sandboxHomeDir, '.agents', 'skills');
26
- await writeSkills({
27
- sandbox,
28
- rootDir: skillRootDir,
29
- skills,
30
- ...(abortSignal ? { abortSignal } : {}),
31
- });
32
- return skillRootDir;
34
+ }): ClineSkillsRuntime {
35
+ const projectedSkills = projectClineSkills({ skills });
36
+ const signature = JSON.stringify(projectedSkills);
37
+ if (projectedSkills.length === 0) {
38
+ return { signature };
39
+ }
40
+
41
+ const executor: NonNullable<ToolExecutors['skills']> = async (
42
+ ...executorArguments
43
+ ) => {
44
+ const [requestedSkill, args] = executorArguments;
45
+ const skill = projectedSkills.find(
46
+ candidate => candidate.id === normalizeSkillToken(requestedSkill),
47
+ );
48
+ if (skill == null) {
49
+ const availableSkills = projectedSkills.map(skill => skill.name);
50
+ return availableSkills.length > 0
51
+ ? `Skill "${requestedSkill}" not found. Available skills: ${availableSkills.join(', ')}`
52
+ : 'No skills are currently available.';
53
+ }
54
+ return renderSkillInstructions({ skill, args });
55
+ };
56
+ executor.configuredSkills = projectedSkills.map(skill => ({
57
+ id: skill.id,
58
+ name: skill.name,
59
+ description: skill.description,
60
+ disabled: false,
61
+ }));
62
+
63
+ const tool = createDefaultTools({
64
+ executors: { skills: executor },
65
+ enableReadFiles: false,
66
+ enableSearch: false,
67
+ enableBash: false,
68
+ enableWebFetch: false,
69
+ enableApplyPatch: false,
70
+ enableEditor: false,
71
+ enableSkills: true,
72
+ enableAskQuestion: false,
73
+ enableSubmitAndExit: false,
74
+ }).find(tool => tool.name === 'skills');
75
+ if (tool == null) {
76
+ throw new Error('Cline did not create its native skills tool.');
77
+ }
78
+
79
+ return { signature, tool };
33
80
  }
34
81
 
35
- /**
36
- * Render the system-prompt section that advertises available skills. Only the
37
- * name + description sit in context; the model loads the full skill via the
38
- * `read` tool when the task calls for it (progressive disclosure).
39
- */
40
- export function renderSkillsPromptSection(
41
- skills: ReadonlyArray<HarnessV1Skill>,
42
- skillRootDir: string,
43
- ): string {
44
- const entries = skills
45
- .map(
46
- skill =>
47
- `- ${skill.name}: ${skill.description}\n SKILL.md: ${path.posix.join(
48
- skillRootDir,
49
- skill.name,
50
- 'SKILL.md',
51
- )}`,
52
- )
53
- .join('\n');
54
- return (
55
- '## Skills\n\n' +
56
- 'The following skills are available. When a task matches a skill, read its SKILL.md with the `read` tool and follow its instructions.\n\n' +
57
- entries
58
- );
82
+ function projectClineSkills({
83
+ skills,
84
+ }: {
85
+ skills: ReadonlyArray<HarnessV1Skill>;
86
+ }): ReadonlyArray<ProjectedClineSkill> {
87
+ const names = new Set<string>();
88
+ const ids = new Set<string>();
89
+ return skills
90
+ .map(skill => {
91
+ if (
92
+ !CLINE_SKILL_NAME_PATTERN.test(skill.name) ||
93
+ skill.name === '.' ||
94
+ skill.name === '..'
95
+ ) {
96
+ throw new Error(`Invalid Cline skill name: ${skill.name}`);
97
+ }
98
+ if (names.has(skill.name)) {
99
+ throw new Error(`Duplicate Cline skill name: ${skill.name}`);
100
+ }
101
+ names.add(skill.name);
102
+
103
+ const id = normalizeSkillToken(skill.name);
104
+ if (ids.has(id)) {
105
+ throw new Error(`Duplicate Cline skill identifier: ${id}`);
106
+ }
107
+ ids.add(id);
108
+
109
+ return {
110
+ id,
111
+ name: skill.name,
112
+ description: skill.description,
113
+ content: skill.content,
114
+ files: (skill.files ?? [])
115
+ .map(file => ({
116
+ path: normalizeSkillFilePath({
117
+ skillName: skill.name,
118
+ filePath: file.path,
119
+ }),
120
+ content: file.content,
121
+ }))
122
+ .sort(
123
+ (left, right) =>
124
+ left.path.localeCompare(right.path) ||
125
+ left.content.localeCompare(right.content),
126
+ ),
127
+ };
128
+ })
129
+ .sort((left, right) => left.name.localeCompare(right.name));
130
+ }
131
+
132
+ function normalizeSkillToken(skill: string): string {
133
+ return skill.trim().replace(/^\/+/, '').toLowerCase();
134
+ }
135
+
136
+ function normalizeSkillFilePath({
137
+ skillName,
138
+ filePath,
139
+ }: {
140
+ skillName: string;
141
+ filePath: string;
142
+ }): string {
143
+ const normalized = path.posix.normalize(filePath);
144
+ if (
145
+ normalized === '' ||
146
+ normalized === '.' ||
147
+ normalized.startsWith('../') ||
148
+ normalized.includes('/../') ||
149
+ normalized.endsWith('/..') ||
150
+ path.posix.isAbsolute(normalized)
151
+ ) {
152
+ throw new Error(
153
+ `Invalid Cline skill file path for ${skillName}: ${filePath}`,
154
+ );
155
+ }
156
+ return normalized;
157
+ }
158
+
159
+ function renderSkillInstructions({
160
+ skill,
161
+ args,
162
+ }: {
163
+ skill: ProjectedClineSkill;
164
+ args: string | undefined;
165
+ }): string {
166
+ const trimmedArgs = args?.trim();
167
+ const argsTag = trimmedArgs
168
+ ? `\n<command-args>${trimmedArgs}</command-args>`
169
+ : '';
170
+ const description = skill.description.trim()
171
+ ? `Description: ${skill.description.trim()}\n\n`
172
+ : '';
173
+ const files =
174
+ skill.files.length === 0
175
+ ? ''
176
+ : `\n\n<skill-files>\n${skill.files
177
+ .map(
178
+ file =>
179
+ `<skill-file path=${JSON.stringify(file.path)}>\n${file.content}\n</skill-file>`,
180
+ )
181
+ .join('\n')}\n</skill-files>`;
182
+
183
+ return `<command-name>${skill.name}</command-name>${argsTag}\n<command-instructions>\n${description}${skill.content}${files}\n</command-instructions>`;
59
184
  }