fix: resolve OpenCode source imports and installed skills paths

This commit is contained in:
Yusuf Efe
2026-09-14 02:26:53 +03:00
parent 8321021c54
commit ef475c7997
15 changed files with 257 additions and 44 deletions
+1 -1
View File
@@ -37,4 +37,4 @@
// Export the main plugin
// opencode's legacy plugin loader iterates every module export and throws if
// any is not a plugin function, so only the plugin function may be exported.
export { default } from "./plugins/index.js"
export { default } from "./plugins/index.ts"
+1 -1
View File
@@ -11,7 +11,7 @@
"devDependencies": {
"@opencode-ai/plugin": "^1.4.3",
"@types/node": "^20.0.0",
"typescript": "^5.3.0"
"typescript": "^5.7.0"
},
"engines": {
"node": ">=18.0.0"
+1 -1
View File
@@ -62,7 +62,7 @@
"devDependencies": {
"@opencode-ai/plugin": "^1.4.3",
"@types/node": "^20.0.0",
"typescript": "^5.3.0"
"typescript": "^5.7.0"
},
"engines": {
"node": ">=18.0.0"
+4 -4
View File
@@ -16,8 +16,8 @@
import type { PluginInput } from "@opencode-ai/plugin"
import * as fs from "fs"
import * as path from "path"
import changedFilesTool from "../tools/changed-files.js"
import dependencyAnalyzerTool from "../tools/dependency-analyzer.js"
import changedFilesTool from "../tools/changed-files.ts"
import dependencyAnalyzerTool from "../tools/dependency-analyzer.ts"
/**
* Type definitions for better type safety
@@ -111,9 +111,9 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
// This plugin is OpenCode's startup entry point, so a static import
// failure here previously crashed the whole plugin -- and with it, the
// entire OpenCode session -- before any hooks could load (see #2530).
let changedFilesStore: typeof import("./lib/changed-files-store.js") | undefined
let changedFilesStore: typeof import("./lib/changed-files-store.ts") | undefined
try {
const store = await import("./lib/changed-files-store.js")
const store = await import("./lib/changed-files-store.ts")
store.initStore(worktreePath)
changedFilesStore = store
} catch {
+2 -2
View File
@@ -6,7 +6,7 @@
* while taking advantage of OpenCode's more sophisticated 20+ event types.
*/
export { ECCHooksPlugin, default } from "./ecc-hooks.js"
export { ECCHooksPlugin, default } from "./ecc-hooks.ts"
// Re-export for named imports
export * from "./ecc-hooks.js"
export * from "./ecc-hooks.ts"
+3 -3
View File
@@ -1,5 +1,5 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import type { ChangeType, TreeNode } from "../plugins/lib/changed-files-store.js"
import type { ChangeType, TreeNode } from "../plugins/lib/changed-files-store.ts"
const INDICATORS: Record<ChangeType, string> = {
added: "+",
@@ -27,12 +27,12 @@ function renderTree(nodes: TreeNode[], indent: string): string {
// file, so a static import failure here previously took down the entire
// tools module -- and with it, the whole OpenCode session -- on the very
// first tool-loading pass (see #2530).
type ChangedFilesStore = typeof import("../plugins/lib/changed-files-store.js")
type ChangedFilesStore = typeof import("../plugins/lib/changed-files-store.ts")
let changedFilesStorePromise: Promise<ChangedFilesStore> | undefined
async function loadChangedFilesStore(): Promise<ChangedFilesStore> {
if (!changedFilesStorePromise) {
changedFilesStorePromise = import("../plugins/lib/changed-files-store.js").catch(() => {
changedFilesStorePromise = import("../plugins/lib/changed-files-store.ts").catch(() => {
changedFilesStorePromise = undefined
throw new Error(
"changed-files tool: could not load the changed-files store. " +
+9 -9
View File
@@ -4,12 +4,12 @@
* These tools extend OpenCode with additional capabilities.
*/
// Re-export all tools
export { default as runTests } from "./run-tests.js"
export { default as checkCoverage } from "./check-coverage.js"
export { default as securityAudit } from "./security-audit.js"
export { default as formatCode } from "./format-code.js"
export { default as lintCheck } from "./lint-check.js"
export { default as gitSummary } from "./git-summary.js"
export { default as changedFiles } from "./changed-files.js"
export { default as dependencyAnalyzer } from "./dependency-analyzer.js"
// OpenCode loads these sources in place; tsc rewrites .ts imports for dist.
export { default as runTests } from "./run-tests.ts"
export { default as checkCoverage } from "./check-coverage.ts"
export { default as securityAudit } from "./security-audit.ts"
export { default as formatCode } from "./format-code.ts"
export { default as lintCheck } from "./lint-check.ts"
export { default as gitSummary } from "./git-summary.ts"
export { default as changedFiles } from "./changed-files.ts"
export { default as dependencyAnalyzer } from "./dependency-analyzer.ts"
+1
View File
@@ -3,6 +3,7 @@
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rewriteRelativeImportExtensions": true,
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": ".",
+2 -12
View File
@@ -32,7 +32,7 @@ const {
updateSettingsAtomic,
validateManagedHooks,
} = require('./install/claude-settings');
const { adaptAntigravityAgent } = require('./install/antigravity-agent');
const { transformInstallContent } = require('./install/content-transform');
const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite');
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
const { resolveInvocationEnvironment } = require('./invocation-environment');
@@ -223,18 +223,8 @@ function buildLinkIndexForOperations(operations, trustedRoot) {
return buildInstallIndex(mappings);
}
function transformCopyFileContent(operation, content) {
if (!operation.contentTransform) {
return content;
}
if (operation.contentTransform === 'antigravity-agent-frontmatter') {
return adaptAntigravityAgent(content, operation.sourceRelativePath);
}
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
}
function getExpectedCopyFileContent(operation, content, linkIndex) {
const transformed = transformCopyFileContent(operation, content);
const transformed = transformInstallContent(operation, content);
if (!linkIndex || !operation.sourceRelativePath || !isMarkdownPath(operation.destinationPath)) {
return transformed;
}
@@ -5,6 +5,8 @@ const path = require('path');
const {
buildValidationIssue,
createInstallTargetAdapter,
createManagedOperation,
isForeignPlatformPath,
} = require('./helpers');
const { resolveOpencodeConfigRoot } = require('../opencode-paths');
@@ -88,5 +90,27 @@ module.exports = createInstallTargetAdapter({
resolveRoot: resolveOpencodeConfigRoot,
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.opencode',
planOperations(input, adapter) {
const modules = Array.isArray(input.modules)
? input.modules
: (input.module ? [input.module] : []);
return modules.flatMap(module => (Array.isArray(module.paths) ? module.paths : [])
.filter(sourcePath => !isForeignPlatformPath(sourcePath, adapter.target))
.flatMap(sourcePath => {
const operation = adapter.createScaffoldOperation(module.id, sourcePath, input);
if (operation.sourceRelativePath !== '.opencode') {
return [operation];
}
// Keep the repository template's sibling skills path for direct use,
// but point home installs at the skills copied inside the config root.
return [operation, createManagedOperation({
moduleId: module.id,
sourceRelativePath: '.opencode/opencode.json',
destinationPath: path.join(operation.destinationPath, 'opencode.json'),
strategy: operation.strategy,
contentTransform: 'opencode-home-skills-path',
})];
}));
},
validate: defaultValidateOpencodeHome,
});
+1 -11
View File
@@ -35,22 +35,12 @@ const {
} = require('./ownership-guard');
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
const { adaptAntigravityAgent } = require('./antigravity-agent');
const { transformInstallContent } = require('./content-transform');
function isMarkdownPath(filePath) {
return /\.(md|mdx|markdown)$/i.test(String(filePath || ''));
}
function transformInstallContent(operation, content) {
if (!operation.contentTransform) {
return content;
}
if (operation.contentTransform === 'antigravity-agent-frontmatter') {
return adaptAntigravityAgent(content, operation.sourceRelativePath);
}
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
}
// Map every copy-file operation to { sourceRel, destRel } so relative links in
// namespaced markdown can be rewritten to the file's actual installed location
// (issue #2340). Returns null when the plan lacks the data needed to do so.
+29
View File
@@ -0,0 +1,29 @@
'use strict';
const { adaptAntigravityAgent } = require('./antigravity-agent');
function transformInstallContent(operation, content) {
if (!operation.contentTransform) {
return content;
}
if (operation.contentTransform === 'antigravity-agent-frontmatter') {
return adaptAntigravityAgent(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'opencode-home-skills-path') {
const config = JSON.parse(content);
if (!Array.isArray(config?.skills?.paths) || !config.skills.paths.includes('../skills')) {
return content;
}
const installedConfig = {
...config,
skills: {
...config.skills,
paths: config.skills.paths.map(skillPath => skillPath === '../skills' ? './skills' : skillPath),
},
};
return `${JSON.stringify(installedConfig, null, 2)}\n`;
}
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
}
module.exports = { transformInstallContent };
+18
View File
@@ -0,0 +1,18 @@
import { readFile } from "node:fs/promises"
import ts from "typescript"
// Emulate OpenCode's in-place TypeScript loading without remapping missing
// .js specifiers to .ts files. Such remapping would hide issue #3112.
export async function load(url, context, nextLoad) {
if (!url.endsWith(".ts")) return nextLoad(url, context)
const source = await readFile(new URL(url), "utf8")
const result = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
verbatimModuleSyntax: true,
},
})
return { format: "module", source: result.outputText, shortCircuit: true }
}
+77
View File
@@ -21,6 +21,7 @@ const {
} = require('../../scripts/lib/install-executor');
const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/install/apply');
const { withHookConsent } = require('../../scripts/lib/install/hook-consent');
const { repairInstalledStates, uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle');
const REPO_ROOT = path.resolve(__dirname, '..', '..');
@@ -709,6 +710,82 @@ function runTests() {
}
})) passed++; else failed++;
if (test('OpenCode home installs resolve bundled skills inside the config root', () => {
const tempDir = createTempDir('install-executor-opencode-skills-');
try {
const sourceRoot = path.join(tempDir, 'source');
writeManifestSourceFixture(sourceRoot);
const modulesPath = path.join(sourceRoot, 'manifests', 'install-modules.json');
const manifest = JSON.parse(fs.readFileSync(modulesPath, 'utf8'));
writeJson(sourceRoot, 'manifests/install-modules.json', {
...manifest,
modules: manifest.modules.map(module => ({
...module,
paths: ['.opencode', '.cursor', 'skills/demo'],
targets: ['opencode'],
})),
});
writeFile(sourceRoot, '.opencode/dist/index.js', 'export default () => ({});\n');
writeFile(sourceRoot, '.opencode/dist/plugins/index.js', 'export {};\n');
writeFile(sourceRoot, '.opencode/dist/tools/index.js', 'export {};\n');
writeFile(sourceRoot, '.cursor/foreign.txt', 'must not install\n');
const sourceConfig = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'opencode.json'), 'utf8'));
const configs = [
{
...sourceConfig,
skills: { ...sourceConfig.skills, paths: ['../skills', '/custom/skills', '../other-skills'] },
},
{ ...sourceConfig, skills: { paths: ['/custom/skills'] } },
{ plugin: ['./plugins'] },
];
for (const [index, config] of configs.entries()) {
const configSourcePath = path.join(sourceRoot, '.opencode', 'opencode.json');
writeJson(sourceRoot, '.opencode/opencode.json', config);
const originalContent = fs.readFileSync(configSourcePath, 'utf8');
const plan = createManifestInstallPlan({
sourceRoot,
projectRoot: path.join(tempDir, 'project'),
homeDir: path.join(tempDir, `home-${index}`),
target: 'opencode',
moduleIds: ['fixture-core'],
});
const configPath = path.join(plan.targetRoot, 'opencode.json');
assert.strictEqual(plan.operations.filter(operation => operation.destinationPath === configPath).length, 1);
applyInstallPlan(plan);
const installed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const expected = index === 0
? { ...config, skills: { ...config.skills, paths: ['./skills', '/custom/skills', '../other-skills'] } }
: config;
assert.deepStrictEqual(installed, expected);
assert.strictEqual(fs.readFileSync(configSourcePath, 'utf8'), originalContent, 'repository template must stay unchanged');
assert.ok(fs.existsSync(path.join(plan.targetRoot, 'skills', 'demo', 'SKILL.md')));
assert.ok(!fs.existsSync(path.join(plan.targetRoot, '.cursor')));
if (index === 0) {
const lifecycleOptions = {
repoRoot: sourceRoot,
projectRoot: path.join(tempDir, 'project'),
homeDir: path.join(tempDir, `home-${index}`),
targets: ['opencode'],
};
fs.unlinkSync(configPath);
const repaired = repairInstalledStates(lifecycleOptions);
assert.strictEqual(repaired.results[0].status, 'repaired', repaired.results[0].error);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(configPath, 'utf8')), expected);
const state = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8'));
const recordedConfig = state.operations.find(operation => operation.destinationPath === configPath);
assert.strictEqual(recordedConfig.contentSha256, crypto.createHash('sha256')
.update(fs.readFileSync(configPath)).digest('hex'));
const uninstalled = uninstallInstalledStates(lifecycleOptions);
assert.strictEqual(uninstalled.results[0].status, 'uninstalled', uninstalled.results[0].error);
assert.ok(!fs.existsSync(configPath));
}
}
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('dedupeCopyFileOperations keeps the last writer per destination (issue #2414)', () => {
// Mirrors the OpenCode command scenario: a generic commands/<name>.md source
// (preserve-relative-path) and an override .opencode/commands/<name>.md source
+84
View File
@@ -6,7 +6,57 @@ const assert = require("assert")
const fs = require("fs")
const path = require("path")
const { spawnSync } = require("child_process")
const { pathToFileURL } = require("url")
const { getNpmPackEntry } = require("../lib/npm-pack-output")
const { createManifestInstallPlan, applyInstallPlan } = require("../../scripts/lib/install-executor")
const { withHookConsent } = require("../../scripts/lib/install/hook-consent")
function checkRuntime(runtimeRoot, extension, loader) {
const check = `
const assert = require("node:assert/strict")
const path = require("node:path")
const { pathToFileURL } = require("node:url")
const [root, extension] = process.argv.slice(1)
const load = (name) => import(pathToFileURL(path.join(root, name + extension)).href)
async function main() {
const tools = await load("tools/index")
assert.deepEqual(Object.keys(tools).sort(), [
"changedFiles", "checkCoverage", "dependencyAnalyzer", "formatCode",
"gitSummary", "lintCheck", "runTests", "securityAudit",
])
for (const tool of Object.values(tools)) {
assert.equal(typeof tool.description, "string")
assert.equal(typeof tool.execute, "function")
}
const plugins = await load("plugins/index")
const entry = await load("index")
assert.deepEqual(Object.keys(entry), ["default"])
assert.equal(entry.default, plugins.default)
const logs = []
const plugin = await entry.default({
client: { app: { log: (event) => logs.push(event.body) } },
$: () => { throw new Error("Unexpected shell execution") },
directory: process.cwd(),
worktree: process.cwd(),
})
assert.equal(typeof plugin["session.created"], "function")
await plugin["file.edited"]({ path: path.join(process.cwd(), "example.txt") })
const changed = JSON.parse(await tools.changedFiles.execute({ format: "json" }, {}))
assert.equal(changed.changed, true)
assert.deepEqual(changed.files, [{ path: "example.txt", changeType: "modified" }])
assert.ok(!logs.some((log) => log.message.includes("tracking disabled")))
}
main().catch((error) => { console.error(error); process.exit(1) })
`
const args = loader ? ["--loader", pathToFileURL(loader).href] : []
const result = spawnSync(process.execPath, [...args, "-e", check, runtimeRoot, extension], {
cwd: path.join(__dirname, "..", ".."),
encoding: "utf8",
})
assert.strictEqual(result.status, 0, result.error?.message || result.stderr || result.stdout)
}
function runTest(name, fn) {
try {
@@ -116,6 +166,40 @@ function main() {
})
assert.strictEqual(result.status, 0, result.stderr)
}],
["compiled barrels load and share the lazy changed-files store", () => {
checkRuntime(path.dirname(distEntry), ".js")
}],
["full home install loads TypeScript barrels and the lazy changed-files store", () => {
// Keep the fixture beneath the repo so the installed tools can resolve
// the real SDK dependency without installing packages in the user's home.
const fixture = fs.mkdtempSync(path.join(repoRoot, "tests", ".opencode-home-"))
try {
const homeDir = path.join(fixture, "home")
const projectRoot = path.join(fixture, "project")
fs.mkdirSync(homeDir)
fs.mkdirSync(projectRoot)
const plan = withHookConsent(createManifestInstallPlan({
sourceRoot: repoRoot,
homeDir,
projectRoot,
env: {},
target: "opencode",
profileId: "full",
}), "enabled")
applyInstallPlan(plan)
const config = JSON.parse(fs.readFileSync(path.join(plan.targetRoot, "opencode.json"), "utf8"))
assert.deepStrictEqual(config.skills.paths, ["./skills"])
assert.ok(fs.existsSync(path.join(plan.targetRoot, config.skills.paths[0], "tdd-workflow", "SKILL.md")))
assert.ok(!fs.existsSync(path.join(plan.targetRoot, "tools", "run-tests.js")))
checkRuntime(
plan.targetRoot,
".ts",
path.join(repoRoot, "tests", "fixtures", "opencode-ts-loader.mjs"),
)
} finally {
fs.rmSync(fixture, { recursive: true, force: true })
}
}],
["npm pack includes the compiled OpenCode dist payload", () => {
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
cwd: repoRoot,