diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index cea0148bb95..a725ee32f32 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -1,5 +1,6 @@ export * as ConfigAgent from "./agent" +import path from "path" // kilocode_change import { Exit, Schema, SchemaGetter } from "effect" import { Bus } from "@/bus" import { zod } from "@/util/effect-zod" @@ -13,6 +14,7 @@ import * as ConfigMarkdown from "./markdown" import { ConfigModelID } from "./model-id" import { ConfigParse } from "./parse" import { ConfigPermission } from "./permission" +import { ConfigVariable } from "./variable" // kilocode_change // kilocode_change start import { KilocodeConfig } from "@/kilocode/config/config" import type { Warning } from "./config" @@ -162,11 +164,21 @@ export async function load(dir: string, warnings?: Warning[]) { // kilocode_change end const name = configEntryNameFromPath(item, patterns) + // kilocode_change start - substitute agent prompt variables relative to the agent file + const prompt = await ConfigVariable.substitute({ + text: md.content.trim(), + type: "virtual", + dir: path.dirname(item), + source: item, + missing: "empty", + escapeJson: false, + }) const config = { name, ...md.data, - prompt: md.content.trim(), + prompt, } + // kilocode_change end // kilocode_change start - use Effect schema (propertyOrder: original) + non-fatal handleInvalid try { result[config.name] = ConfigParse.effectSchema(Info, config, item) as Info diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 390f7f8b06a..7708af95add 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -2,6 +2,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import matter from "gray-matter" import { z } from "zod" import { Filesystem } from "@/util/filesystem" +import { KilocodeMarkdown } from "../kilocode/config/markdown" // kilocode_change export const FILE_REGEX = /(? { return process.env[varName] || "" }) @@ -81,7 +83,7 @@ export async function substitute(input: SubstituteInput) { }) ).trim() - out += JSON.stringify(fileContent).slice(1, -1) + out += escape ? JSON.stringify(fileContent).slice(1, -1) : fileContent // kilocode_change cursor = index + token.length } diff --git a/packages/opencode/src/kilocode/config/markdown.ts b/packages/opencode/src/kilocode/config/markdown.ts new file mode 100644 index 00000000000..2b8147a7660 --- /dev/null +++ b/packages/opencode/src/kilocode/config/markdown.ts @@ -0,0 +1,38 @@ +import os from "os" +import path from "path" +import { Filesystem } from "@/util/filesystem" + +export namespace KilocodeMarkdown { + function ref(token: string) { + const file = token.replace(/^\{file:/, "").replace(/\}$/, "") + if (file.startsWith("~/")) return path.join(os.homedir(), file.slice(2)) + return file + } + + export async function substitute(text: string, item: string) { + const body = text.replace(/\{env:([^}]+)\}/g, (_, name) => process.env[name] || "") + const matches = Array.from(body.matchAll(/\{file:[^}]+\}/g)) + if (!matches.length) return body + + const dir = path.dirname(item) + const chunks = await Promise.all( + matches.map(async (match, i) => { + const token = match[0] + const index = match.index ?? 0 + const prev = matches[i - 1] + const cursor = prev ? (prev.index ?? 0) + prev[0].length : 0 + const head = body.slice(cursor, index) + const start = body.lastIndexOf("\n", index - 1) + 1 + const prefix = body.slice(start, index).trimStart() + if (prefix.startsWith("//")) return head + token + + const file = ref(token) + const target = path.isAbsolute(file) ? file : path.resolve(dir, file) + const content = await Filesystem.readText(target).catch(() => "") + return head + content.trim() + }), + ) + const last = matches.at(-1) + return chunks.join("") + (last ? body.slice((last.index ?? 0) + last[0].length) : "") + } +} diff --git a/packages/opencode/src/kilocode/session/instruction.ts b/packages/opencode/src/kilocode/session/instruction.ts new file mode 100644 index 00000000000..cd3ec481a1c --- /dev/null +++ b/packages/opencode/src/kilocode/session/instruction.ts @@ -0,0 +1,7 @@ +import { KilocodeMarkdown } from "../config/markdown" + +export namespace KilocodeInstruction { + export function content(text: string, item: string) { + return KilocodeMarkdown.substitute(text, item) + } +} diff --git a/packages/opencode/src/kilocode/workflows-migrator.ts b/packages/opencode/src/kilocode/workflows-migrator.ts index cd915389098..c8c475c5780 100644 --- a/packages/opencode/src/kilocode/workflows-migrator.ts +++ b/packages/opencode/src/kilocode/workflows-migrator.ts @@ -4,6 +4,7 @@ import os from "os" import type { Config } from "../config/config" import type { ConfigCommand } from "../config/command" import { Filesystem } from "../util/filesystem" +import { KilocodeMarkdown } from "./config/markdown" import { KilocodePaths } from "./paths" export namespace WorkflowsMigrator { @@ -59,7 +60,7 @@ export namespace WorkflowsMigrator { const files = await findWorkflowFiles(dir) const workflows: KilocodeWorkflow[] = [] for (const file of files) { - const content = await fs.readFile(file, "utf-8") + const content = await KilocodeMarkdown.substitute(await fs.readFile(file, "utf-8"), file) workflows.push({ name: extractNameFromFilename(file), path: file, diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index d0be1dd401d..de3e0e08756 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -7,6 +7,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "@opencode-ai/core/global" +import { KilocodeInstruction } from "@/kilocode/session/instruction" // kilocode_change import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" @@ -88,7 +89,8 @@ export const layer: Layer.Layer< }) const read = Effect.fnUntraced(function* (filepath: string) { - return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) + const content = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) // kilocode_change + return yield* Effect.promise(() => KilocodeInstruction.content(content, filepath)) // kilocode_change }) const fetch = Effect.fnUntraced(function* (url: string) { diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index cfe58435ac5..de1f9343387 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -11,6 +11,7 @@ import { Npm } from "@opencode-ai/core/npm" import { Account } from "../../../src/account/account" import { Auth } from "../../../src/auth" import { Config } from "../../../src/config/config" +import { ConfigMarkdown } from "../../../src/config/markdown" import { Env } from "../../../src/env" import { KiloIndexing } from "../../../src/kilocode/indexing" import { Instance } from "../../../src/project/instance" @@ -64,12 +65,33 @@ const cfg: Partial = { }, } -describe("kilocode indexing config", () => { - afterEach(async () => { - await disposeAllInstances() - await clear(true) - }) +afterEach(async () => { + delete process.env.KILO_MD_TEST + await disposeAllInstances() + await clear(true) +}) +describe("markdown substitutions", () => { + test("applies file and env substitutions to parsed markdown body", async () => { + process.env.KILO_MD_TEST = "env content" + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "body.md"), "file content") + await Filesystem.write( + path.join(dir, "SKILL.md"), + ["---", "name: test", "description: Test", "---", "{file:body.md}", "{env:KILO_MD_TEST}"].join("\n"), + ) + }, + }) + + const md = await ConfigMarkdown.parse(path.join(tmp.path, "SKILL.md")) + + expect(md.content).toContain("file content") + expect(md.content).toContain("env content") + }) +}) + +describe("kilocode indexing config", () => { test("keeps global indexing enabled in global config", async () => { await using globalTmp = await tmpdir() await using tmp = await tmpdir() diff --git a/packages/opencode/test/kilocode/session/instruction-substitution.test.ts b/packages/opencode/test/kilocode/session/instruction-substitution.test.ts new file mode 100644 index 00000000000..abe9e777343 --- /dev/null +++ b/packages/opencode/test/kilocode/session/instruction-substitution.test.ts @@ -0,0 +1,76 @@ +import { describe, expect } from "bun:test" +import path from "node:path" +import { Effect, FileSystem, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { NodeFileSystem } from "@effect/platform-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Config } from "../../../src/config/config" +import { emptyConsoleState } from "../../../src/config/console-state" +import { Instruction } from "../../../src/session/instruction" +import { MessageID } from "../../../src/session/schema" +import { Global } from "@opencode-ai/core/global" +import { provideTmpdirInstance } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) + +const configLayer = Layer.succeed( + Config.Service, + Config.Service.of({ + get: () => Effect.succeed({}), + getGlobal: () => Effect.succeed({}), + getConsoleState: () => Effect.succeed(emptyConsoleState), + update: () => Effect.void, + updateGlobal: (config) => Effect.succeed(config), + invalidate: () => Effect.void, + directories: () => Effect.succeed([]), + waitForDependencies: () => Effect.void, + warnings: () => Effect.succeed([]), + }), +) + +const layer = (dir: string) => + Instruction.layer.pipe( + Layer.provide(configLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Global.layerWith({ home: dir, config: dir })), + ) + +const write = (filepath: string, content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(path.dirname(filepath), { recursive: true }) + yield* fs.writeFileString(filepath, content) + }) + +describe("instruction markdown substitutions", () => { + it.live("applies file and env substitutions to nearby AGENTS.md", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + process.env.KILO_INSTRUCTION_TEST = "env content" + yield* write(path.join(dir, "subdir", "guide.md"), "file content") + yield* write( + path.join(dir, "subdir", "AGENTS.md"), + ["# Instructions", "", "{file:guide.md}", "{env:KILO_INSTRUCTION_TEST}"].join("\n"), + ) + yield* write(path.join(dir, "subdir", "nested", "file.ts"), "const value = 1") + + const svc = yield* Instruction.Service + const results = yield* svc.resolve( + [], + path.join(dir, "subdir", "nested", "file.ts"), + MessageID.ascending(), + ) + + expect(results).toHaveLength(1) + expect(results[0].content).toContain("file content") + expect(results[0].content).toContain("env content") + expect(results[0].content).not.toContain("{file:") + expect(results[0].content).not.toContain("{env:") + delete process.env.KILO_INSTRUCTION_TEST + }).pipe(Effect.provide(layer(dir))), + ), + ) +}) diff --git a/packages/opencode/test/kilocode/workflows-migrator.test.ts b/packages/opencode/test/kilocode/workflows-migrator.test.ts index f8f8c8ba06d..afb40c3c5e5 100644 --- a/packages/opencode/test/kilocode/workflows-migrator.test.ts +++ b/packages/opencode/test/kilocode/workflows-migrator.test.ts @@ -136,6 +136,29 @@ Actual description here.` workflows.some((w) => w.source === "global" && w.path.includes(path.join(".kilo", "workflows", "global.md"))), ).toBe(true) }) + + test("applies markdown substitutions to workflow content", async () => { + process.env.KILO_WORKFLOW_TEST = "env content" + await using tmp = await tmpdir({ + init: async (dir) => { + const workflowsDir = path.join(dir, ".kilo", "workflows") + await Bun.write(path.join(dir, "guide.md"), "file content") + await Bun.write( + path.join(workflowsDir, "workflow.md"), + ["# Workflow", "", "{file:../../guide.md}", "{env:KILO_WORKFLOW_TEST}"].join("\n"), + ) + }, + }) + + try { + const workflows = await WorkflowsMigrator.discoverWorkflows(tmp.path, true) + + expect(workflows[0].content).toContain("file content") + expect(workflows[0].content).toContain("env content") + } finally { + delete process.env.KILO_WORKFLOW_TEST + } + }) }) describe("convertToCommand", () => {