diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 335c3d4681d..7951a65033b 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -16,44 +16,9 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { parse as parseYamlDocument } from "yaml"; import { expandHomePath } from "../../pathExpansion.ts"; - -type ClaudeSkillScope = "user" | "project"; - -const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; - -type SkillFrontmatter = - | { readonly kind: "missing" } - | { readonly kind: "malformed" } - | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; - -function parseSkillFrontmatter(contents: string): SkillFrontmatter { - const match = FRONTMATTER_PATTERN.exec(contents); - if (!match) { - return { kind: "missing" }; - } - - let parsed: unknown; - try { - parsed = parseYamlDocument(match[1] ?? ""); - } catch { - return { kind: "malformed" }; - } - if (typeof parsed !== "object" || parsed === null) { - return { kind: "malformed" }; - } - - const record = parsed as Record; - const name = typeof record.name === "string" ? record.name.trim() : ""; - const description = typeof record.description === "string" ? record.description.trim() : ""; - return { - kind: "parsed", - ...(name ? { name } : {}), - ...(description ? { description } : {}), - }; -} +import { discoverSkillsFromRoots } from "./ProviderSkills.ts"; /** * Resolve the Claude config directory the CLI would use, matching the @@ -95,54 +60,11 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* cwd?: string, environment?: NodeJS.ProcessEnv, ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); - const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ + return yield* discoverSkillsFromRoots([ { directory: path.join(configDirPath, "skills"), scope: "user" }, ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), - ]; - - const skillsByName = new Map(); - for (const root of roots) { - const entries = yield* fileSystem - .readDirectory(root.directory) - .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); - - for (const entry of [...entries].sort()) { - const skillPath = path.join(root.directory, entry, "SKILL.md"); - const contents = yield* fileSystem - .readFileString(skillPath) - .pipe(Effect.orElseSucceed(() => undefined)); - if (contents === undefined) { - continue; - } - - const frontmatter = parseSkillFrontmatter(contents); - // Malformed frontmatter means the skill won't load in Claude Code - // either — skip it rather than surfacing a broken entry under its - // directory name. - if (frontmatter.kind === "malformed") { - continue; - } - - const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); - if (!name) { - continue; - } - - skillsByName.set(name, { - name, - path: skillPath, - enabled: true, - scope: root.scope, - ...(frontmatter.kind === "parsed" && frontmatter.description - ? { description: frontmatter.description } - : {}), - }); - } - } - - return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); + ]); }); diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 112f1101316..0986fbc1e43 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -86,6 +86,9 @@ export const GrokDriver: ProviderDriver = { Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -113,10 +116,12 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts new file mode 100644 index 00000000000..5a29e49e787 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -0,0 +1,94 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverGrokSkills } from "./GrokSkills.ts"; + +const writeSkill = Effect.fn("writeGrokSkill")(function* ( + root: string, + directoryName: string, + description: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDirectory = path.join(root, directoryName); + yield* fs.makeDirectory(skillDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(skillDirectory, "SKILL.md"), + ["---", `name: ${directoryName}`, `description: ${description}`, "---"].join("\n"), + ); +}); + +it.layer(NodeServices.layer)("discoverGrokSkills", (it) => { + it.effect("scans Grok user and project roots in override order", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-skills-" }); + const homeDirectory = path.join(tempDir, "home"); + const cwd = path.join(tempDir, "workspace"); + const roots = [ + path.join(homeDirectory, ".agents", "skills"), + path.join(homeDirectory, ".grok", "skills"), + path.join(cwd, ".agents", "skills"), + path.join(cwd, ".grok", "skills"), + ]; + + yield* Effect.forEach(roots, (root, index) => writeSkill(root, "shared", `root-${index}`), { + discard: true, + }); + yield* writeSkill(roots[0]!, "agents-user", "From user agents."); + yield* writeSkill(roots[1]!, "grok-user", "From user Grok."); + yield* writeSkill(roots[2]!, "agents-project", "From project agents."); + yield* writeSkill(roots[3]!, "grok-project", "From project Grok."); + + const skills = yield* discoverGrokSkills(cwd, {}, homeDirectory); + + assert.deepEqual( + skills.map(({ name, scope, description }) => ({ name, scope, description })), + [ + { name: "agents-project", scope: "project", description: "From project agents." }, + { name: "agents-user", scope: "user", description: "From user agents." }, + { name: "grok-project", scope: "project", description: "From project Grok." }, + { name: "grok-user", scope: "user", description: "From user Grok." }, + { name: "shared", scope: "project", description: "root-3" }, + ], + ); + assert.equal( + skills.find((skill) => skill.name === "shared")?.path, + path.join(roots[3]!, "shared", "SKILL.md"), + ); + }), + ); + + it.effect("uses USERPROFILE as the user home on Windows", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-windows-home-" }); + const userProfile = path.join(tempDir, "user-profile"); + const fallbackHome = path.join(tempDir, "server-home"); + const skillsRoot = path.join(userProfile, ".agents", "skills"); + yield* writeSkill(skillsRoot, "windows-user", "From USERPROFILE."); + + const skills = yield* discoverGrokSkills( + undefined, + { USERPROFILE: userProfile }, + fallbackHome, + ).pipe(Effect.provideService(HostProcessPlatform, "win32")); + + assert.deepEqual(skills, [ + { + name: "windows-user", + description: "From USERPROFILE.", + path: path.join(skillsRoot, "windows-user", "SKILL.md"), + scope: "user", + enabled: true, + }, + ]); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts new file mode 100644 index 00000000000..304beba2ffa --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -0,0 +1,44 @@ +import * as NodeOS from "node:os"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverSkillsFromRoots } from "./ProviderSkills.ts"; + +export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, + fallbackHomeDirectory = NodeOS.homedir(), +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const environmentHome = + (platform === "win32" ? environment.USERPROFILE : environment.HOME)?.trim() ?? ""; + const homeDirectory = + environmentHome.length > 0 + ? cwd + ? path.resolve(cwd, environmentHome) + : path.resolve(environmentHome) + : fallbackHomeDirectory; + const environmentGrokHome = environment.GROK_HOME?.trim() ?? ""; + const grokHome = + environmentGrokHome.length > 0 + ? cwd + ? path.resolve(cwd, environmentGrokHome) + : path.resolve(environmentGrokHome) + : path.join(homeDirectory, ".grok"); + + return yield* discoverSkillsFromRoots([ + { directory: path.join(homeDirectory, ".agents", "skills"), scope: "user" }, + { directory: path.join(grokHome, "skills"), scope: "user" }, + ...(cwd + ? [ + { directory: path.join(cwd, ".agents", "skills"), scope: "project" }, + { directory: path.join(cwd, ".grok", "skills"), scope: "project" }, + ] + : []), + ]); +}); diff --git a/apps/server/src/provider/Drivers/ProviderSkills.test.ts b/apps/server/src/provider/Drivers/ProviderSkills.test.ts new file mode 100644 index 00000000000..6cfd35521dd --- /dev/null +++ b/apps/server/src/provider/Drivers/ProviderSkills.test.ts @@ -0,0 +1,111 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverSkillsFromRoots } from "./ProviderSkills.ts"; + +it.layer(NodeServices.layer)("discoverSkillsFromRoots", (it) => { + it.effect("discovers skill metadata from each root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-skills-" }); + const userRoot = path.join(tempDir, "user-skills"); + const projectRoot = path.join(tempDir, "project-skills"); + const userSkillPath = path.join(userRoot, "review", "SKILL.md"); + const projectSkillPath = path.join(projectRoot, "deploy", "SKILL.md"); + + yield* fs.makeDirectory(path.dirname(userSkillPath), { recursive: true }); + yield* fs.writeFileString( + userSkillPath, + ["---", "name: review", "description: Review the change.", "---"].join("\n"), + ); + yield* fs.makeDirectory(path.dirname(projectSkillPath), { recursive: true }); + yield* fs.writeFileString( + projectSkillPath, + ["---", "name: deploy", "description: Deploy the app.", "---"].join("\n"), + ); + + const skills = yield* discoverSkillsFromRoots([ + { directory: userRoot, scope: "user" }, + { directory: projectRoot, scope: "project" }, + ]); + + assert.deepEqual(skills, [ + { + name: "deploy", + description: "Deploy the app.", + path: projectSkillPath, + scope: "project", + enabled: true, + }, + { + name: "review", + description: "Review the change.", + path: userSkillPath, + scope: "user", + enabled: true, + }, + ]); + }), + ); + + it.effect("lets later roots win and skips missing roots and malformed frontmatter", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-skills-" }); + const userRoot = path.join(tempDir, "user-skills"); + const projectRoot = path.join(tempDir, "project-skills"); + const userSkillPath = path.join(userRoot, "deploy", "SKILL.md"); + const projectSkillPath = path.join(projectRoot, "deploy", "SKILL.md"); + const malformedSkillPath = path.join(projectRoot, "broken", "SKILL.md"); + const unterminatedSkillPath = path.join(projectRoot, "unterminated", "SKILL.md"); + const nonStringFieldsSkillPath = path.join(projectRoot, "fallback-name", "SKILL.md"); + + yield* fs.makeDirectory(path.dirname(userSkillPath), { recursive: true }); + yield* fs.writeFileString( + userSkillPath, + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* fs.makeDirectory(path.dirname(projectSkillPath), { recursive: true }); + yield* fs.writeFileString( + projectSkillPath, + ["---", "name: deploy", "description: Project deploy.", "---"].join("\n"), + ); + yield* fs.makeDirectory(path.dirname(malformedSkillPath), { recursive: true }); + yield* fs.writeFileString(malformedSkillPath, "---\nname: [unclosed\n---\n"); + yield* fs.makeDirectory(path.dirname(unterminatedSkillPath), { recursive: true }); + yield* fs.writeFileString(unterminatedSkillPath, "---\nname: unfinished\n"); + yield* fs.makeDirectory(path.dirname(nonStringFieldsSkillPath), { recursive: true }); + yield* fs.writeFileString( + nonStringFieldsSkillPath, + ["---", "name: 2024", "description:", " nested: value", "---"].join("\n"), + ); + + const skills = yield* discoverSkillsFromRoots([ + { directory: path.join(tempDir, "missing"), scope: "user" }, + { directory: userRoot, scope: "user" }, + { directory: projectRoot, scope: "project" }, + ]); + + assert.deepEqual(skills, [ + { + name: "deploy", + description: "Project deploy.", + path: projectSkillPath, + scope: "project", + enabled: true, + }, + { + name: "fallback-name", + path: nonStringFieldsSkillPath, + scope: "project", + enabled: true, + }, + ]); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ProviderSkills.ts b/apps/server/src/provider/Drivers/ProviderSkills.ts new file mode 100644 index 00000000000..ec450709014 --- /dev/null +++ b/apps/server/src/provider/Drivers/ProviderSkills.ts @@ -0,0 +1,103 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; +import { parse as parseYamlDocument } from "yaml"; + +export interface ProviderSkillRoot { + readonly directory: string; + readonly scope: string; +} + +const FRONTMATTER_OPENING_PATTERN = /^---(?:\r?\n|$)/; +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; +const SkillFrontmatter = Schema.Struct({ + name: Schema.optional(Schema.Unknown), + description: Schema.optional(Schema.Unknown), +}); +const decodeSkillFrontmatter = Schema.decodeUnknownOption(SkillFrontmatter); + +type ParsedSkillFrontmatter = + | { readonly kind: "missing" } + | { readonly kind: "malformed" } + | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; + +function parseSkillFrontmatter(contents: string): ParsedSkillFrontmatter { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) { + return { kind: FRONTMATTER_OPENING_PATTERN.test(contents) ? "malformed" : "missing" }; + } + + let document: unknown; + try { + document = parseYamlDocument(match[1] ?? ""); + } catch { + return { kind: "malformed" }; + } + + const decoded = decodeSkillFrontmatter(document); + if (Option.isNone(decoded)) { + return { kind: "malformed" }; + } + + const name = Predicate.isString(decoded.value.name) ? decoded.value.name.trim() : undefined; + const description = Predicate.isString(decoded.value.description) + ? decoded.value.description.trim() + : undefined; + return { + kind: "parsed", + ...(name ? { name } : {}), + ...(description ? { description } : {}), + }; +} + +export const discoverSkillsFromRoots = Effect.fn("discoverSkillsFromRoots")(function* ( + roots: ReadonlyArray, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillsByName = new Map(); + + for (const root of roots) { + const entries = yield* fileSystem + .readDirectory(root.directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + for (const entry of [...entries].sort()) { + const skillPath = path.join(root.directory, entry, "SKILL.md"); + const contents = yield* fileSystem + .readFileString(skillPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) { + continue; + } + + const frontmatter = parseSkillFrontmatter(contents); + // Syntactically malformed frontmatter is skipped instead of surfacing a + // potentially broken skill under its directory name. + if (frontmatter.kind === "malformed") { + continue; + } + + const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); + if (!name) { + continue; + } + + skillsByName.set(name, { + name, + path: skillPath, + scope: root.scope, + enabled: true, + ...(frontmatter.kind === "parsed" && frontmatter.description + ? { description: frontmatter.description } + : {}), + }); + } + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 000243869c9..258eb50a810 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -81,14 +82,20 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); - it.effect("reports an error when ACP model discovery is unavailable", () => + it.effect("keeps discovered skills when ACP model discovery is unavailable", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-success-" }); + const skillPath = path.join(dir, ".grok", "skills", "review", "SKILL.md"); const grokPath = path.join(dir, "grok"); + yield* fs.makeDirectory(path.dirname(skillPath), { recursive: true }); + yield* fs.writeFileString( + skillPath, + ["---", "name: review", "description: Review the change.", "---"].join("\n"), + ); yield* fs.writeFileString( grokPath, ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), @@ -97,6 +104,8 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + process.env, + dir, ); }), ); @@ -105,6 +114,96 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { expect(snapshot.installed).toBe(true); expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); expect(snapshot.message).toContain("ACP startup failed"); + expect(snapshot.skills.find((skill) => skill.name === "review")).toEqual({ + name: "review", + description: "Review the change.", + path: expect.stringContaining("/.grok/skills/review/SKILL.md"), + scope: "project", + enabled: true, + }); + }), + ); + + it.effect("discovers user skills from GROK_HOME", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-home-" }); + const workspace = path.join(dir, "workspace"); + const grokHome = path.join(dir, "custom-grok-home"); + const skillPath = path.join(grokHome, "skills", "review", "SKILL.md"); + const grokPath = path.join(dir, "grok"); + yield* fs.makeDirectory(path.dirname(skillPath), { recursive: true }); + yield* fs.makeDirectory(workspace, { recursive: true }); + yield* fs.writeFileString( + skillPath, + ["---", "name: review", "description: Review from GROK_HOME.", "---"].join("\n"), + ); + yield* fs.writeFileString( + grokPath, + ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), + ); + yield* fs.chmod(grokPath, 0o755); + + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, GROK_HOME: grokHome }, + workspace, + ); + }), + ); + + expect(snapshot.skills.find((skill) => skill.name === "review")).toEqual({ + name: "review", + description: "Review from GROK_HOME.", + path: expect.stringContaining("/custom-grok-home/skills/review/SKILL.md"), + scope: "user", + enabled: true, + }); + }), + ); + + it.effect("discovers user skills from the provider environment home", () => + Effect.gen(function* () { + const { snapshot, skillPath } = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-env-home-" }); + const workspace = path.join(dir, "workspace"); + const instanceHome = path.join(dir, "instance-home"); + const skillPath = path.join(instanceHome, ".agents", "skills", "review", "SKILL.md"); + const grokPath = path.join(dir, "grok"); + yield* fs.makeDirectory(path.dirname(skillPath), { recursive: true }); + yield* fs.makeDirectory(workspace, { recursive: true }); + yield* fs.writeFileString( + skillPath, + ["---", "name: review", "description: Review from instance home.", "---"].join("\n"), + ); + yield* fs.writeFileString( + grokPath, + ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), + ); + yield* fs.chmod(grokPath, 0o755); + + const snapshot = yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, HOME: instanceHome, GROK_HOME: undefined }, + workspace, + ).pipe(Effect.provideService(HostProcessPlatform, "linux")); + return { snapshot, skillPath }; + }), + ); + + expect(snapshot.skills.find((skill) => skill.name === "review")).toEqual({ + name: "review", + description: "Review from instance home.", + path: skillPath, + scope: "user", + enabled: true, + }); }), ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae..3c854fa1b5f 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -10,7 +10,9 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -30,6 +32,7 @@ import { type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -126,6 +129,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( const discoverGrokModelsViaAcp = ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + cwd = process.cwd(), ) => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -133,7 +137,7 @@ const discoverGrokModelsViaAcp = ( grokSettings, environment, childProcessSpawner, - cwd: process.cwd(), + cwd, clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, }); const started = yield* acp.start(); @@ -161,10 +165,11 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + cwd?: string, ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path > { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = grokModelsFromSettings(grokSettings.customModels); @@ -251,10 +256,12 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( - Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - Effect.exit, - ); + const skills = yield* discoverGrokSkills(cwd, environment); + const discoveryExit = yield* discoverGrokModelsViaAcp( + grokSettings, + environment, + cwd ?? process.cwd(), + ).pipe(Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit); if (Exit.isFailure(discoveryExit)) { yield* Effect.logWarning("Grok ACP model discovery failed", { errorTag: causeErrorTag(discoveryExit.cause), @@ -264,6 +271,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -282,6 +290,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -302,6 +311,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models, + skills, probe: { installed: true, version,