fix: resolve installed OpenCode skill paths consistently

Carry forward PR #3115 ef475c79 and 780dfb78 onto current main. Preserve the newer hook-consent transforms during install and repair. Main already includes the source import fix from #3167.
This commit is contained in:
Yusuf Efe
2026-10-09 15:40:12 -04:00
committed by haelyra
parent ef648e0189
commit 0f34fdcc14
11 changed files with 342 additions and 41 deletions
+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"
+1 -1
View File
@@ -4,7 +4,7 @@
* These tools extend OpenCode with additional capabilities.
*/
// Re-export all tools
// 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"
+2 -20
View File
@@ -14,8 +14,6 @@ const { writeFileNoFollow: guardedWriteFile } = require('./install/guarded-write
const { withOpenCodeInstallLocks } = require('./install/opencode-install-lock');
const { isCodexUserConfig } = require('./install/codex-user-config');
const {
disableOpenCodeHookPluginRegistration,
getDisabledOpenCodePluginContent,
getRecordedHookConsent,
withHookConsent,
} = require('./install/hook-consent');
@@ -41,7 +39,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');
@@ -232,24 +230,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);
}
if (operation.contentTransform === 'opencode-disable-ecc-hooks') {
return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') {
return getDisabledOpenCodePluginContent();
}
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 -17
View File
@@ -51,28 +51,12 @@ const {
prepareExcludedPathsReconciliation,
} = require('./excluded-paths-reconciliation');
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);
}
if (operation.contentTransform === 'opencode-disable-ecc-hooks') {
return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') {
return getDisabledOpenCodePluginContent();
}
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.
+37
View File
@@ -0,0 +1,37 @@
'use strict';
const { adaptAntigravityAgent } = require('./antigravity-agent');
const { disableOpenCodeHookPluginRegistration, getDisabledOpenCodePluginContent } = require('./hook-consent');
function transformInstallContent(operation, content) {
if (!operation.contentTransform) {
return content;
}
if (operation.contentTransform === 'antigravity-agent-frontmatter') {
return adaptAntigravityAgent(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'opencode-disable-ecc-hooks') {
return transformInstallContent({ ...operation, contentTransform: 'opencode-home-skills-path' },
disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath));
}
if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') {
return getDisabledOpenCodePluginContent();
}
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 }
}
+172 -1
View File
@@ -23,6 +23,7 @@ const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/
const { normalizeInstallRequest } = require('../../scripts/lib/install/request');
const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime');
const { withHookConsent } = require('../../scripts/lib/install/hook-consent');
const { repairInstalledStates, uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle');
const REPO_ROOT = path.resolve(__dirname, '..', '..');
@@ -745,6 +746,174 @@ 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 dormantConfig = { ...config, plugin: config.plugin.filter(plugin => plugin !== './plugins') };
const expected = index === 0
? { ...dormantConfig, skills: { ...config.skills, paths: ['./skills', '/custom/skills', '../other-skills'] } }
: dormantConfig;
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('OpenCode pre-fix managed sources upgrade on repair and uninstall preserves user changes', () => {
const tempDir = createTempDir('install-executor-opencode-upgrade-');
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'],
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');
writeJson(sourceRoot, '.opencode/opencode.json', { plugin: ['./plugins'] });
fs.cpSync(path.join(REPO_ROOT, '.opencode', 'tools'), path.join(sourceRoot, '.opencode', 'tools'), {
recursive: true,
});
const currentContent = fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'tools', 'index.ts'), 'utf8');
const legacyContent = [
'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"',
'',
].join('\n');
const digest = content => crypto.createHash('sha256').update(content).digest('hex');
for (const upgrade of [false, true]) {
writeFile(sourceRoot, '.opencode/tools/index.ts', legacyContent);
const homeDir = path.join(tempDir, upgrade ? 'repair-home' : 'uninstall-home');
const projectRoot = path.join(tempDir, 'project');
const plan = createManifestInstallPlan({
sourceRoot, projectRoot, homeDir, target: 'opencode', moduleIds: ['fixture-core'],
});
applyInstallPlan(plan);
const barrelPath = path.join(plan.targetRoot, 'tools', 'index.ts');
const toolPath = path.join(plan.targetRoot, 'tools', 'run-tests.ts');
const userPath = writeFile(plan.targetRoot, 'tools/user-note.txt', 'Keep my settings.\n');
const installedState = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8'));
const recordedBarrel = installedState.operations.find(operation => operation.destinationPath === barrelPath);
assert.strictEqual(fs.readFileSync(barrelPath, 'utf8'), legacyContent);
assert.strictEqual(recordedBarrel.contentTransform, undefined, 'TypeScript imports are copied, not transformed');
assert.strictEqual(recordedBarrel.contentSha256, digest(legacyContent));
assert.ok(fs.existsSync(toolPath));
assert.ok(!fs.existsSync(path.join(plan.targetRoot, 'tools', 'run-tests.js')), 'legacy import has no adjacent JavaScript file');
// Upgrade the repository independently of the existing managed home.
writeFile(sourceRoot, '.opencode/tools/index.ts', currentContent);
const lifecycleOptions = { repoRoot: sourceRoot, projectRoot, homeDir, targets: ['opencode'] };
if (upgrade) {
const repaired = repairInstalledStates(lifecycleOptions);
assert.strictEqual(repaired.results.length, 1);
assert.strictEqual(repaired.results[0].status, 'repaired', JSON.stringify(repaired));
assert.ok(repaired.results[0].repairedPaths.includes(barrelPath));
const repairedContent = fs.readFileSync(barrelPath, 'utf8');
assert.strictEqual(repairedContent, currentContent);
assert.ok(repairedContent.includes('from "./run-tests.ts"'));
assert.ok(!repairedContent.includes('.js"'), 'repair replaces legacy JavaScript specifiers');
const repairedState = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8'));
const repairedBarrel = repairedState.operations.find(operation => operation.destinationPath === barrelPath);
assert.strictEqual(repairedBarrel.contentSha256, digest(currentContent));
assert.notStrictEqual(repairedBarrel.contentSha256, recordedBarrel.contentSha256);
assert.strictEqual(fs.readFileSync(userPath, 'utf8'), 'Keep my settings.\n');
}
const modifiedContent = `${fs.readFileSync(toolPath, 'utf8')}\n// User test customisation.\n`;
fs.writeFileSync(toolPath, modifiedContent, 'utf8');
const uninstalled = uninstallInstalledStates(lifecycleOptions);
assert.strictEqual(uninstalled.results.length, 1);
assert.strictEqual(uninstalled.results[0].status, 'partial', JSON.stringify(uninstalled));
assert.ok(uninstalled.results[0].removedPaths.includes(barrelPath));
assert.ok(!fs.existsSync(barrelPath), 'both legacy and repaired managed barrels are recognised');
assert.deepStrictEqual(uninstalled.results[0].retainedPaths, [toolPath]);
assert.strictEqual(fs.readFileSync(toolPath, 'utf8'), modifiedContent);
assert.strictEqual(fs.readFileSync(userPath, 'utf8'), 'Keep my settings.\n');
assert.ok(fs.existsSync(plan.installStatePath), 'modified managed content retains its ownership record');
}
} 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
@@ -934,6 +1103,7 @@ function runTests() {
'utf8'
));
assert.ok(!installedConfig.plugin.includes('./plugins'));
assert.deepStrictEqual(installedConfig.skills.paths, ['./skills']);
const enabledWithoutRuntime = createInstallPlanFromRequest(
normalizeInstallRequest({
@@ -985,8 +1155,9 @@ function runTests() {
const enabledConfig = enabledPlan.operations.find(operation => (
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
));
assert.strictEqual(enabledConfig.contentTransform, undefined);
assert.strictEqual(enabledConfig.contentTransform, 'opencode-home-skills-path');
applyInstallPlanDirect(enabledPlan);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(enabledConfig.destinationPath, 'utf8')).skills.paths, ['./skills']);
for (const name of OPENCODE_ENTRYPOINTS) {
assert.strictEqual(
fs.readFileSync(path.join(homeDir, '.config', 'opencode', 'plugins', name), 'utf8'),
+1
View File
@@ -1997,6 +1997,7 @@ function runTests() {
const expectedContent = `${JSON.stringify({
...sourceConfig,
plugin: sourceConfig.plugin.filter(plugin => plugin !== './plugins'),
skills: { ...sourceConfig.skills, paths: ['./skills'] },
}, null, 2)}\n`;
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(destinationPath, expectedContent, 'utf8');
+84
View File
@@ -7,7 +7,57 @@ const fs = require("fs")
const os = require("os")
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 {
@@ -183,6 +233,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", () => {
fs.rmSync(path.dirname(distEntry), { recursive: true, force: true })
const result = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts=false"], {