收口 Makelore 客户端变更
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-12 22:35:06 +08:00
parent 253bad8b40
commit f4113a872f
232 changed files with 4617 additions and 20121 deletions

View File

@@ -90,6 +90,12 @@ export interface OpencodeSkillInfo {
description?: string;
location: string;
content: string;
entries?: OpencodeSkillEntry[];
}
export interface OpencodeSkillEntry {
path: string;
type: 'file' | 'directory';
}
export interface RevertOpencodeSessionMessageInput {

View File

@@ -0,0 +1,112 @@
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
rmSync,
unlinkSync,
} from 'node:fs';
import { join } from 'node:path';
export interface BundledSkillsPathInput {
isPackaged: boolean;
resourcesPath: string;
appPath: string;
}
export interface EnsureBundledCourseSkillsOptions {
managedConfigDir: string;
sourceDir?: string;
}
export const BUNDLED_COURSE_SKILL_IDS = [
'agent-browser',
'deploy-publish-check',
'designer-design-spec',
'dev-build-test',
'game-assets',
'marketing-launch-story',
'nianxxgame-skill',
'partner-agent-showcase',
'pm-project-plan',
'product-demo-prototype',
'ui-ux-course-quality',
'youth-plain-language',
'youth-ai-product-course',
] as const;
const RETIRED_COURSE_SKILL_IDS = ['course-stage-review', 'student-growth-logger'] as const;
const LEGACY_SUPERPOWERS_ARTIFACTS = [
'plugins/superpowers-niancode.js',
'plugins/superpowers.js',
'superpowers-active.json',
'superpowers-bundles',
] as const;
export function resolveBundledCourseSkillsDir(input: BundledSkillsPathInput): string {
return input.isPackaged
? join(input.resourcesPath, 'course-skills')
: join(input.appPath, '.opencode', 'skills');
}
export function resolveBundledAgentBrowserPluginPath(input: BundledSkillsPathInput): string {
return join(
resolveBundledCourseSkillsDir(input),
'agent-browser',
'.opencode',
'plugins',
'niancode-agent-browser.js',
);
}
export function getManagedOpencodeConfigDir(userDataDir: string): string {
return join(userDataDir, 'opencode', 'niancode-config');
}
function copyDirectorySync(sourceDir: string, targetDir: string): void {
cpSync(sourceDir, targetDir, {
recursive: true,
force: true,
filter: (sourcePath, targetPath) => {
// Node 24.14's Windows override path can corrupt non-ASCII destinations
// when replacing a file. Public unlink keeps force-overwrite semantics.
if (lstatSync(sourcePath).isFile() && existsSync(targetPath)) {
unlinkSync(targetPath);
}
return true;
},
});
}
export function removeLegacySuperpowersArtifacts(managedConfigDir: string): void {
for (const artifact of LEGACY_SUPERPOWERS_ARTIFACTS) {
rmSync(join(managedConfigDir, artifact), { recursive: true, force: true });
}
}
export function ensureBundledCourseSkills(options: EnsureBundledCourseSkillsOptions): boolean {
const sourceDir = options.sourceDir?.trim();
if (!sourceDir || !existsSync(sourceDir)) {
return false;
}
const targetSkillsDir = join(options.managedConfigDir, 'skills');
mkdirSync(targetSkillsDir, { recursive: true });
for (const skillId of RETIRED_COURSE_SKILL_IDS) {
rmSync(join(targetSkillsDir, skillId), { recursive: true, force: true });
}
copyDirectorySync(sourceDir, targetSkillsDir);
return true;
}
export function ensureBundledAgentBrowserPlugin(options: {
managedConfigDir: string;
sourcePath?: string;
}): boolean {
const sourcePath = options.sourcePath?.trim();
if (!sourcePath || !existsSync(sourcePath)) return false;
const targetPluginsDir = join(options.managedConfigDir, 'plugins');
mkdirSync(targetPluginsDir, { recursive: true });
cpSync(sourcePath, join(targetPluginsDir, 'niancode-agent-browser.js'), { force: true });
return true;
}

View File

@@ -11,9 +11,9 @@ import {
import {
ensureBundledAgentBrowserPlugin,
ensureBundledCourseSkills,
ensureBundledSuperpowersPlugin,
getManagedOpencodeConfigDir,
} from './superpowers';
removeLegacySuperpowersArtifacts,
} from './course-skills';
import { logger } from '../utils/logger';
import {
prependManagedRuntimesToPath,
@@ -164,7 +164,6 @@ export interface OpencodeManagerOptions {
port: number;
binPath: string;
userDataDir?: string;
bundledSuperpowersDir?: string;
bundledCourseSkillsDir?: string;
bundledAgentBrowserPluginPath?: string;
pythonRuntime?: PythonRuntime;
@@ -1049,10 +1048,7 @@ export class OpencodeManager extends EventEmitter {
mkdirSync(dataHome, { recursive: true });
mkdirSync(cacheHome, { recursive: true });
mkdirSync(managedConfigDir, { recursive: true });
ensureBundledSuperpowersPlugin({
managedConfigDir,
sourceDir: this.options.bundledSuperpowersDir,
});
removeLegacySuperpowersArtifacts(managedConfigDir);
ensureBundledCourseSkills({
managedConfigDir,
sourceDir: this.options.bundledCourseSkillsDir,

View File

@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { existsSync, readFileSync, realpathSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
export interface RuntimePathInput {
isPackaged: boolean;
@@ -47,11 +48,31 @@ function getNativeOpencodeBinName(platform: NodeJS.Platform): string {
return platform === 'win32' ? 'opencode.exe' : 'opencode';
}
function resolveDevelopmentNativeBinPath(input: RuntimePathInput): string | undefined {
function resolveNativePackageRoot(runtimeDir: string, packageName: string): string | undefined {
try {
const resolvedRuntimeDir = realpathSync(runtimeDir);
const runtimeRequire = createRequire(join(resolvedRuntimeDir, 'package.json'));
return dirname(runtimeRequire.resolve(`${packageName}/package.json`));
} catch {
return undefined;
}
}
function resolveDevelopmentNativeBinPath(
input: RuntimePathInput,
runtimeDir: string,
): string | undefined {
const arch = input.arch ?? process.arch;
for (const packageName of getDevelopmentNativePackageCandidates(input.platform, arch)) {
const binPath = join(input.appPath, 'node_modules', packageName, 'bin', getNativeOpencodeBinName(input.platform));
if (existsSync(binPath)) return binPath;
const binaryName = getNativeOpencodeBinName(input.platform);
const hoistedBinPath = join(input.appPath, 'node_modules', packageName, 'bin', binaryName);
if (existsSync(hoistedBinPath)) return hoistedBinPath;
const nativePackageRoot = resolveNativePackageRoot(runtimeDir, packageName);
if (!nativePackageRoot) continue;
const virtualStoreBinPath = join(nativePackageRoot, 'bin', binaryName);
if (existsSync(virtualStoreBinPath)) return virtualStoreBinPath;
}
return undefined;
}
@@ -73,7 +94,7 @@ export function resolveOpencodeRuntimePaths(input: RuntimePathInput): OpencodeRu
: join(input.appPath, 'node_modules', OPENCODE_RUNTIME_PACKAGE);
const developmentNativeBinPath = input.isPackaged
? undefined
: resolveDevelopmentNativeBinPath(input);
: resolveDevelopmentNativeBinPath(input, runtimeDir);
return {
runtimeDir,

View File

@@ -31,6 +31,21 @@ function normalizeProjectType(value: unknown): ProjectType {
throw new Error('Invalid project type');
}
function needsLegacyAgentModelMigration(value: unknown): boolean {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const raw = value as Partial<ProjectConfig>;
const defaultModel = typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
? raw.defaultModel.trim()
: null;
if (!defaultModel || !Array.isArray(raw.agents)) return false;
return raw.agents.some((agent) => (
Boolean(agent)
&& typeof agent === 'object'
&& !(typeof (agent as { model?: unknown }).model === 'string'
&& (agent as { model?: string }).model?.trim())
));
}
function normalizeAgent(value: unknown): ProjectAgentConfig | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Partial<ProjectAgentConfig>;
@@ -80,9 +95,16 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
}
const raw = value as Partial<ProjectConfig>;
if (raw.schemaVersion !== 1) throw new Error('Unsupported project config schema');
const defaultModel = typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
? raw.defaultModel.trim()
: null;
const agents = Array.isArray(raw.agents) ? raw.agents.map(normalizeAgent) : [];
if (agents.some((item) => !item)) throw new Error('Invalid project Agent configuration');
const normalizedAgents = agents.filter((item): item is ProjectAgentConfig => Boolean(item));
const normalizedAgents = agents
.filter((item): item is ProjectAgentConfig => Boolean(item))
.map((agent) => agent.model || !defaultModel
? agent
: { ...agent, model: defaultModel });
if (new Set(normalizedAgents.map((item) => item.id)).size !== normalizedAgents.length) {
throw new Error('Duplicate project Agent id');
}
@@ -97,10 +119,9 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
schemaVersion: 1,
projectType: normalizeProjectType(raw.projectType),
initialized,
superpowersEnabled: raw.superpowersEnabled === true,
defaultModel: typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
? raw.defaultModel.trim()
: null,
// Keep this legacy field readable for compatibility, but runtime model
// selection is owned by each project Agent.
defaultModel,
agents: normalizedAgents,
knowledgeDirectory: 'knowledge',
createdAt,
@@ -111,7 +132,12 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
export async function readProjectConfig(projectPath: string): Promise<ProjectConfigReadResult> {
try {
const raw = JSON.parse(await readFile(configPath(projectPath), 'utf8')) as unknown;
return { status: 'valid', config: normalizeProjectConfig(raw) };
const config = normalizeProjectConfig(raw);
if (needsLegacyAgentModelMigration(raw)) {
if (config.initialized) await materializeAgents(projectPath, config);
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
}
return { status: 'valid', config };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' };
return { status: 'invalid', error: error instanceof Error ? error.message : String(error) };
@@ -165,7 +191,7 @@ function buildAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): s
? agent.skillIds.map((skill) => ` ${skill}: allow`).join('\n')
: ' "*": deny';
const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : '';
const model = agent.model ?? config.defaultModel;
const model = agent.model;
const prompt = agent.prompt.trim() || buildProjectAgentPrompt(config, agent);
return `---
description: ${yamlString(agent.name)}

View File

@@ -1,6 +1,6 @@
import { readdir, readFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import type { OpencodeSkillInfo } from './client';
import { basename, dirname, join, relative, sep } from 'node:path';
import type { OpencodeSkillEntry, OpencodeSkillInfo } from './client';
const SKILL_ROOT_NAMES = ['skill', 'skills'] as const;
@@ -29,6 +29,36 @@ async function collectSkillFiles(root: string): Promise<string[]> {
return files;
}
async function collectSkillEntries(root: string, current = root): Promise<OpencodeSkillEntry[]> {
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === 'ENOENT') {
return [];
}
throw error;
}
entries.sort((left, right) => {
const directoryOrder = Number(right.isDirectory()) - Number(left.isDirectory());
return directoryOrder || left.name.localeCompare(right.name);
});
const result: OpencodeSkillEntry[] = [];
for (const entry of entries) {
const entryPath = join(current, entry.name);
const relativePath = relative(root, entryPath).split(sep).join('/');
if (entry.isDirectory()) {
result.push({ path: relativePath, type: 'directory' });
result.push(...await collectSkillEntries(root, entryPath));
} else if (entry.isFile()) {
result.push({ path: relativePath, type: 'file' });
}
}
return result;
}
function unquoteYamlScalar(value: string): string {
const trimmed = value.trim();
if (
@@ -73,6 +103,7 @@ export async function listInstalledOpencodeSkills(managedConfigDir?: string | nu
description: readFrontmatterScalar(content, 'description'),
location,
content,
entries: await collectSkillEntries(dirname(location)),
});
}

View File

@@ -0,0 +1,92 @@
import type { OpencodeStatus } from './manager';
type ProjectConfigReadResult = {
status: 'valid' | 'missing' | 'invalid';
config?: { initialized?: boolean };
};
export type OpencodeStartupWarmupSkipReason =
| 'no-session'
| 'runtime-active'
| 'no-active-project'
| 'project-not-ready'
| 'no-provider'
| 'eligibility-check-failed'
| 'start-failed';
export type OpencodeStartupWarmupResult =
| { started: true; status: OpencodeStatus }
| { started: false; reason: OpencodeStartupWarmupSkipReason; error?: unknown };
export interface OpencodeStartupWarmupDependencies {
hasAuthenticatedSession: () => boolean;
getStatus: () => OpencodeStatus;
getActiveProject: () => Promise<{ path: string } | null>;
readProjectConfig: (projectPath: string) => Promise<ProjectConfigReadResult>;
getConfiguredProviderCount: () => Promise<number>;
start: () => Promise<OpencodeStatus>;
onError?: (error: unknown, phase: 'eligibility' | 'start') => void;
}
function reportError(
dependencies: OpencodeStartupWarmupDependencies,
error: unknown,
phase: 'eligibility' | 'start',
): void {
try {
dependencies.onError?.(error, phase);
} catch {
// Logging must never turn a background warmup into an unhandled rejection.
}
}
/**
* Starts the local runtime in the background when the persisted app state is
* ready for a Code session. This deliberately does not throw: startup
* warmup is an optimization and the normal Chat-page lazy start remains the
* recovery path when it is unavailable.
*/
export async function warmupOpencodeRuntime(
dependencies: OpencodeStartupWarmupDependencies,
): Promise<OpencodeStartupWarmupResult> {
if (!dependencies.hasAuthenticatedSession()) {
return { started: false, reason: 'no-session' };
}
const status = dependencies.getStatus();
if (status.state !== 'stopped') {
return { started: false, reason: 'runtime-active' };
}
let project: { path: string } | null;
let projectConfig: ProjectConfigReadResult;
let providerCount: number;
try {
project = await dependencies.getActiveProject();
if (!project) {
return { started: false, reason: 'no-active-project' };
}
projectConfig = await dependencies.readProjectConfig(project.path);
if (projectConfig.status !== 'valid' || projectConfig.config?.initialized !== true) {
return { started: false, reason: 'project-not-ready' };
}
providerCount = await dependencies.getConfiguredProviderCount();
} catch (error) {
reportError(dependencies, error, 'eligibility');
return { started: false, reason: 'eligibility-check-failed', error };
}
if (!Number.isFinite(providerCount) || providerCount <= 0) {
return { started: false, reason: 'no-provider' };
}
try {
const startedStatus = await dependencies.start();
return { started: true, status: startedStatus };
} catch (error) {
reportError(dependencies, error, 'start');
return { started: false, reason: 'start-failed', error };
}
}

View File

@@ -1,294 +0,0 @@
import { randomUUID } from 'node:crypto';
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import { retryTransientFilesystemOperation } from './filesystem-retry';
export interface BundledSuperpowersPathInput {
isPackaged: boolean;
resourcesPath: string;
appPath: string;
}
export interface EnsureBundledSuperpowersOptions {
managedConfigDir: string;
sourceDir?: string;
}
export interface EnsureBundledCourseSkillsOptions {
managedConfigDir: string;
sourceDir?: string;
}
const WRAPPER_PLUGIN_NAME = 'superpowers-niancode.js';
const SUPERPOWERS_REPO_DIR = 'superpowers';
const SUPERPOWERS_BUNDLES_DIR = 'superpowers-bundles';
const SUPERPOWERS_ACTIVE_MANIFEST = 'superpowers-active.json';
export const BUNDLED_COURSE_SKILL_IDS = [
'agent-browser',
'designer-design-spec',
'dev-build-test',
'game-assets',
'marketing-launch-story',
'nianxxgame-skill',
'partner-agent-showcase',
'pm-project-plan',
'product-demo-prototype',
'ui-ux-course-quality',
'youth-plain-language',
'youth-ai-product-course',
] as const;
const RETIRED_COURSE_SKILL_IDS = [
'course-stage-review',
'deploy-publish-check',
'student-growth-logger',
] as const;
export function resolveBundledSuperpowersDir(input: BundledSuperpowersPathInput): string {
const resourcesDir = input.isPackaged
? join(input.resourcesPath, 'resources')
: join(input.appPath, 'resources');
return join(resourcesDir, 'skills', SUPERPOWERS_REPO_DIR);
}
export function resolveBundledCourseSkillsDir(input: BundledSuperpowersPathInput): string {
return input.isPackaged
? join(input.resourcesPath, 'course-skills')
: join(input.appPath, '.opencode', 'skills');
}
export function resolveBundledAgentBrowserPluginPath(input: BundledSuperpowersPathInput): string {
return join(
resolveBundledCourseSkillsDir(input),
'agent-browser',
'.opencode',
'plugins',
'niancode-agent-browser.js',
);
}
export function getManagedOpencodeConfigDir(userDataDir: string): string {
return join(userDataDir, 'opencode', 'niancode-config');
}
function readBundledSuperpowersVersion(sourceDir: string): string {
const packagePath = join(sourceDir, 'package.json');
const packageJson = JSON.parse(retryTransientFilesystemOperation(
() => readFileSync(packagePath, 'utf8'),
)) as { version?: unknown };
if (typeof packageJson.version !== 'string' || !packageJson.version.trim()) {
throw new Error(`Bundled Superpowers package has no valid version: ${packagePath}`);
}
return packageJson.version.trim();
}
function toSafeBundleName(version: string): string {
const safeVersion = version.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
if (!safeVersion) {
throw new Error(`Bundled Superpowers version cannot form a safe directory name: ${version}`);
}
return safeVersion;
}
function isCompleteBundle(bundleDir: string, expectedVersion: string): boolean {
try {
if (!existsSync(join(bundleDir, '.opencode', 'plugins', 'superpowers.js'))
|| !existsSync(join(bundleDir, 'skills', 'using-superpowers', 'SKILL.md'))) {
return false;
}
return readBundledSuperpowersVersion(bundleDir) === expectedVersion;
} catch {
return false;
}
}
function resolveManifestBundle(
managedConfigDir: string,
bundlesDir: string,
expectedVersion: string,
): string | null {
try {
const manifest = JSON.parse(retryTransientFilesystemOperation(
() => readFileSync(join(managedConfigDir, SUPERPOWERS_ACTIVE_MANIFEST), 'utf8'),
)) as { version?: unknown; directory?: unknown };
if (manifest.version !== expectedVersion || typeof manifest.directory !== 'string') {
return null;
}
const bundlesRoot = resolve(bundlesDir);
const bundleDir = resolve(bundlesDir, manifest.directory);
const relativeBundlePath = relative(bundlesRoot, bundleDir);
if (!relativeBundlePath
|| relativeBundlePath === '..'
|| relativeBundlePath.startsWith(`..${sep}`)
|| isAbsolute(relativeBundlePath)) {
return null;
}
return isCompleteBundle(bundleDir, expectedVersion) ? bundleDir : null;
} catch {
return null;
}
}
function copyDirectorySync(sourceDir: string, targetDir: string): void {
cpSync(sourceDir, targetDir, {
recursive: true,
force: true,
filter: (sourcePath, targetPath) => {
// Node 24.14's Windows override path can corrupt non-ASCII destinations
// when replacing a file. Public unlink keeps force-overwrite semantics.
if (lstatSync(sourcePath).isFile() && existsSync(targetPath)) {
unlinkSync(targetPath);
}
return true;
},
});
}
function installImmutableBundle(
sourceDir: string,
bundlesDir: string,
safeBundleName: string,
expectedVersion: string,
): string {
retryTransientFilesystemOperation(() => mkdirSync(bundlesDir, { recursive: true }));
const stagingDir = join(
bundlesDir,
`.staging-${safeBundleName}-${process.pid}-${randomUUID()}`,
);
try {
retryTransientFilesystemOperation(() => copyDirectorySync(sourceDir, stagingDir));
if (!isCompleteBundle(stagingDir, expectedVersion)) {
throw new Error(`Bundled Superpowers staging copy is incomplete: ${stagingDir}`);
}
const preferredDir = join(bundlesDir, safeBundleName);
if (isCompleteBundle(preferredDir, expectedVersion)) {
return preferredDir;
}
const selectedDir = existsSync(preferredDir)
? join(bundlesDir, `${safeBundleName}-${randomUUID()}`)
: preferredDir;
try {
retryTransientFilesystemOperation(() => renameSync(stagingDir, selectedDir));
} catch (error) {
if (!isCompleteBundle(selectedDir, expectedVersion)) {
throw error;
}
}
return selectedDir;
} finally {
if (existsSync(stagingDir)) {
try {
retryTransientFilesystemOperation(() => rmSync(stagingDir, {
recursive: true,
force: true,
}));
} catch {
// Startup cleanup is best-effort; preserve the installation result or error.
}
}
}
}
function writeSelectedBundleFiles(
managedConfigDir: string,
bundlesDir: string,
bundleDir: string,
version: string,
): void {
const targetPluginsDir = join(managedConfigDir, 'plugins');
retryTransientFilesystemOperation(() => mkdirSync(targetPluginsDir, { recursive: true }));
const pluginPath = join(bundleDir, '.opencode', 'plugins', 'superpowers.js');
let pluginImport = relative(targetPluginsDir, pluginPath).split(sep).join('/');
if (!pluginImport.startsWith('.')) pluginImport = `./${pluginImport}`;
retryTransientFilesystemOperation(() => writeFileSync(
join(targetPluginsDir, WRAPPER_PLUGIN_NAME),
`export { SuperpowersPlugin } from '${pluginImport}';\n`,
'utf8',
));
retryTransientFilesystemOperation(() => writeFileSync(
join(managedConfigDir, SUPERPOWERS_ACTIVE_MANIFEST),
`${JSON.stringify({
version,
directory: relative(bundlesDir, bundleDir),
}, null, 2)}\n`,
'utf8',
));
}
export function ensureBundledSuperpowersPlugin(options: EnsureBundledSuperpowersOptions): boolean {
const sourceDir = options.sourceDir?.trim();
if (!sourceDir) {
return false;
}
const sourcePluginPath = join(sourceDir, '.opencode', 'plugins', 'superpowers.js');
const sourceSkillsDir = join(sourceDir, 'skills');
if (!existsSync(sourcePluginPath) || !existsSync(sourceSkillsDir)) {
return false;
}
const version = readBundledSuperpowersVersion(sourceDir);
const safeBundleName = toSafeBundleName(version);
const bundlesDir = join(options.managedConfigDir, SUPERPOWERS_BUNDLES_DIR);
const preferredDir = join(bundlesDir, safeBundleName);
const bundleDir = resolveManifestBundle(
options.managedConfigDir,
bundlesDir,
version,
) ?? (isCompleteBundle(preferredDir, version)
? preferredDir
: installImmutableBundle(sourceDir, bundlesDir, safeBundleName, version));
writeSelectedBundleFiles(
options.managedConfigDir,
bundlesDir,
bundleDir,
version,
);
return true;
}
export function ensureBundledCourseSkills(options: EnsureBundledCourseSkillsOptions): boolean {
const sourceDir = options.sourceDir?.trim();
if (!sourceDir || !existsSync(sourceDir)) {
return false;
}
const targetSkillsDir = join(options.managedConfigDir, 'skills');
mkdirSync(targetSkillsDir, { recursive: true });
for (const skillId of RETIRED_COURSE_SKILL_IDS) {
rmSync(join(targetSkillsDir, skillId), { recursive: true, force: true });
}
copyDirectorySync(sourceDir, targetSkillsDir);
return true;
}
export function ensureBundledAgentBrowserPlugin(options: {
managedConfigDir: string;
sourcePath?: string;
}): boolean {
const sourcePath = options.sourcePath?.trim();
if (!sourcePath || !existsSync(sourcePath)) return false;
const targetPluginsDir = join(options.managedConfigDir, 'plugins');
mkdirSync(targetPluginsDir, { recursive: true });
cpSync(sourcePath, join(targetPluginsDir, 'niancode-agent-browser.js'), { force: true });
return true;
}