@jgordijn/opencode-remote-config 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +45 -0
  2. package/dist/index.js +301 -163
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -100,6 +100,7 @@ The plugin reads its configuration from a separate JSON file (not `opencode.json
100
100
 
101
101
  | Option | Type | Default | Description |
102
102
  |--------|------|---------|-------------|
103
+ | `installMethod` | `"link"` or `"copy"` | `"link"` | How to install skills/plugins (symlinks or copies) |
103
104
  | `repositories` | Array | `[]` | List of repositories to sync |
104
105
  | `repositories[].url` | String | Required | Git URL, HTTPS URL, or `file://` path |
105
106
  | `repositories[].ref` | String | Default branch | Branch, tag, or commit SHA (git only) |
@@ -109,6 +110,50 @@ The plugin reads its configuration from a separate JSON file (not `opencode.json
109
110
  | `repositories[].plugins` | `"*"` or `{ include: [...] }` or `{ exclude: [...] }` | All plugins | Plugins to import |
110
111
  | `repositories[].instructions` | `"*"` or `{ include: [...] }` or `{ exclude: [...] }` | All instructions | Instructions from manifest to import |
111
112
 
113
+ ### Installation Method
114
+
115
+ By default, skills and plugins are installed using symlinks. This is fast and efficient but may not work in all environments.
116
+
117
+ #### Configuration
118
+
119
+ ```json
120
+ {
121
+ "installMethod": "link"
122
+ }
123
+ ```
124
+
125
+ | Value | Description |
126
+ |-------|-------------|
127
+ | `link` | Create symlinks (default). Fast, but may not work in containers or on Windows. |
128
+ | `copy` | Copy files. Works everywhere. Uses rsync if available for efficiency. |
129
+
130
+ #### When to Use Copy Mode
131
+
132
+ Use `"installMethod": "copy"` when:
133
+
134
+ - **Dev Containers**: Symlinks break because paths differ between host and container
135
+ - **Windows**: Symlinks require admin privileges or developer mode
136
+ - **Network filesystems**: Some NFS/CIFS mounts don't support symlinks properly
137
+
138
+ #### Example
139
+
140
+ ```json
141
+ {
142
+ "$schema": "https://raw.githubusercontent.com/jgordijn/opencode-remote-config/main/remote-skills.schema.json",
143
+ "installMethod": "copy",
144
+ "repositories": [
145
+ {
146
+ "url": "git@github.com:your-org/shared-skills.git"
147
+ }
148
+ ]
149
+ }
150
+ ```
151
+
152
+ When using copy mode:
153
+ - rsync is preferred if available (efficient incremental updates)
154
+ - Falls back to Node.js file copy if rsync is unavailable
155
+ - Files are kept in sync with the source repository
156
+
112
157
  ### Local Directories
113
158
 
114
159
  For development or local skill repositories, use `file://` URLs:
package/dist/index.js CHANGED
@@ -7475,8 +7475,49 @@ var coerce = {
7475
7475
  var NEVER = INVALID;
7476
7476
  // src/config.ts
7477
7477
  import { existsSync, readFileSync } from "fs";
7478
+ import { join as join2 } from "path";
7479
+ import { homedir as homedir2 } from "os";
7480
+
7481
+ // src/logging.ts
7482
+ import { appendFileSync, mkdirSync } from "fs";
7478
7483
  import { join } from "path";
7479
7484
  import { homedir } from "os";
7485
+ var DEFAULT_LOG_DIR = join(homedir(), ".cache", "opencode", "remote-config");
7486
+ var currentLogDir = DEFAULT_LOG_DIR;
7487
+ var dirEnsured = false;
7488
+ var LOG_PREFIX = "[remote-config]";
7489
+ function getLogFile() {
7490
+ return join(currentLogDir, "plugin.log");
7491
+ }
7492
+ function timestamp() {
7493
+ return new Date().toISOString();
7494
+ }
7495
+ function writeLog(level, message) {
7496
+ try {
7497
+ if (!dirEnsured) {
7498
+ mkdirSync(currentLogDir, { recursive: true });
7499
+ dirEnsured = true;
7500
+ }
7501
+ appendFileSync(getLogFile(), `${timestamp()} [${level}] ${LOG_PREFIX} ${message}
7502
+ `);
7503
+ } catch {
7504
+ dirEnsured = false;
7505
+ }
7506
+ }
7507
+ function log(message) {
7508
+ writeLog("INFO", message);
7509
+ }
7510
+ function logError(message) {
7511
+ writeLog("ERROR", message);
7512
+ }
7513
+ function logDebug(message) {
7514
+ writeLog("DEBUG", message);
7515
+ }
7516
+ function logWarn(message) {
7517
+ writeLog("WARN", message);
7518
+ }
7519
+
7520
+ // src/config.ts
7480
7521
  var CONFIG_FILENAME = "remote-config.json";
7481
7522
  var FilterConfigSchema = exports_external.union([
7482
7523
  exports_external.object({
@@ -7514,16 +7555,18 @@ function shouldImport(name, config) {
7514
7555
  var RemoteSkillsConfigSchema = exports_external.object({
7515
7556
  $schema: exports_external.string().optional(),
7516
7557
  repositories: exports_external.array(RepositoryConfigSchema).default([]),
7517
- sync: exports_external.enum(["blocking", "background"]).default("blocking")
7558
+ sync: exports_external.enum(["blocking", "background"]).default("blocking"),
7559
+ installMethod: exports_external.enum(["link", "copy"]).default("link")
7518
7560
  }).strict();
7519
7561
  var DEFAULT_CONFIG = {
7520
7562
  repositories: [],
7521
- sync: "blocking"
7563
+ sync: "blocking",
7564
+ installMethod: "link"
7522
7565
  };
7523
7566
  function getConfigPaths() {
7524
7567
  return [
7525
- join(process.cwd(), ".opencode", CONFIG_FILENAME),
7526
- join(homedir(), ".config", "opencode", CONFIG_FILENAME)
7568
+ join2(process.cwd(), ".opencode", CONFIG_FILENAME),
7569
+ join2(homedir2(), ".config", "opencode", CONFIG_FILENAME)
7527
7570
  ];
7528
7571
  }
7529
7572
  function loadConfig() {
@@ -7538,13 +7581,13 @@ function loadConfigWithLocation() {
7538
7581
  const parsed = JSON.parse(content);
7539
7582
  const result = RemoteSkillsConfigSchema.safeParse(parsed);
7540
7583
  if (!result.success) {
7541
- console.error(`[remote-config] Invalid configuration in ${configPath}:`, result.error.format());
7584
+ logError(`Invalid configuration in ${configPath}: ${JSON.stringify(result.error.format())}`);
7542
7585
  continue;
7543
7586
  }
7544
- const configDir = join(configPath, "..");
7587
+ const configDir = join2(configPath, "..");
7545
7588
  return { config: result.data, configDir };
7546
7589
  } catch (error) {
7547
- console.error(`[remote-config] Error reading ${configPath}:`, error);
7590
+ logError(`Error reading ${configPath}: ${error}`);
7548
7591
  continue;
7549
7592
  }
7550
7593
  }
@@ -7611,11 +7654,11 @@ var CommandConfigSchema = exports_external.object({
7611
7654
 
7612
7655
  // src/instruction.ts
7613
7656
  import { existsSync as existsSync3 } from "fs";
7614
- import { join as join3, normalize, resolve, sep } from "path";
7657
+ import { join as join4, normalize, resolve, sep } from "path";
7615
7658
 
7616
7659
  // src/manifest.ts
7617
7660
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
7618
- import { join as join2, isAbsolute } from "path";
7661
+ import { join as join3, isAbsolute } from "path";
7619
7662
  var MANIFEST_FILENAME = "manifest.json";
7620
7663
  function containsPathTraversal(path) {
7621
7664
  return path.split("/").some((segment) => segment === ".." || segment === ".");
@@ -7629,7 +7672,7 @@ var ManifestSchema = exports_external.object({
7629
7672
  instructions: exports_external.array(instructionPathSchema).optional().default([])
7630
7673
  });
7631
7674
  function loadManifest(repoPath) {
7632
- const manifestPath = join2(repoPath, MANIFEST_FILENAME);
7675
+ const manifestPath = join3(repoPath, MANIFEST_FILENAME);
7633
7676
  if (!existsSync2(manifestPath)) {
7634
7677
  return { status: "not-found" };
7635
7678
  }
@@ -7679,7 +7722,7 @@ function discoverInstructions(repoPath) {
7679
7722
  console.warn(`[remote-config] Skipping instruction with path traversal: ${instructionName}`);
7680
7723
  continue;
7681
7724
  }
7682
- const absolutePath = join3(repoPath, instructionName);
7725
+ const absolutePath = join4(repoPath, instructionName);
7683
7726
  const resolvedPath = normalize(resolve(absolutePath));
7684
7727
  const resolvedRepoPath = normalize(resolve(repoPath));
7685
7728
  if (!resolvedPath.startsWith(resolvedRepoPath + sep)) {
@@ -7835,14 +7878,14 @@ async function discoverAgents(repoPath) {
7835
7878
  const findAgents = (dir, depth) => {
7836
7879
  if (depth > DISCOVERY_LIMITS.maxDepth) {
7837
7880
  if (!limitsWarned) {
7838
- console.warn(`[remote-config] Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
7881
+ logWarn(`Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
7839
7882
  limitsWarned = true;
7840
7883
  }
7841
7884
  return;
7842
7885
  }
7843
7886
  if (filesProcessed >= DISCOVERY_LIMITS.maxFiles) {
7844
7887
  if (!limitsWarned) {
7845
- console.warn(`[remote-config] Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
7888
+ logWarn(`Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
7846
7889
  limitsWarned = true;
7847
7890
  }
7848
7891
  return;
@@ -7860,7 +7903,7 @@ async function discoverAgents(repoPath) {
7860
7903
  try {
7861
7904
  const stats = fs.statSync(fullPath);
7862
7905
  if (stats.size > DISCOVERY_LIMITS.maxFileSize) {
7863
- console.warn(`[remote-config] Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
7906
+ logWarn(`Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
7864
7907
  continue;
7865
7908
  }
7866
7909
  filesProcessed++;
@@ -7870,7 +7913,7 @@ async function discoverAgents(repoPath) {
7870
7913
  agents.push(parsed);
7871
7914
  }
7872
7915
  } catch (err) {
7873
- console.error(`[remote-config] Failed to parse agent ${fullPath}:`, err);
7916
+ logError(`Failed to parse agent ${fullPath}: ${err}`);
7874
7917
  }
7875
7918
  }
7876
7919
  }
@@ -7896,7 +7939,7 @@ function parseAgentMarkdown(filePath, content, agentDir) {
7896
7939
  });
7897
7940
  } catch (err) {
7898
7941
  const relativeToRepo = path.relative(path.dirname(agentDir), filePath);
7899
- console.error(`[remote-config] Failed to parse frontmatter in ${relativeToRepo}:`, err);
7942
+ logError(`Failed to parse frontmatter in ${relativeToRepo}: ${err}`);
7900
7943
  return null;
7901
7944
  }
7902
7945
  if (!md.data || Object.keys(md.data).length === 0) {
@@ -7906,7 +7949,7 @@ function parseAgentMarkdown(filePath, content, agentDir) {
7906
7949
  const agentName = relativePath.replace(/\.md$/i, "");
7907
7950
  if (!/^[a-zA-Z0-9_/-]+$/.test(agentName)) {
7908
7951
  const relativeToRepo = path.relative(path.dirname(agentDir), filePath);
7909
- console.warn(`[remote-config] Skipping agent with invalid name characters: ${relativeToRepo}`);
7952
+ logWarn(`Skipping agent with invalid name characters: ${relativeToRepo}`);
7910
7953
  return null;
7911
7954
  }
7912
7955
  const rawConfig = {
@@ -7915,7 +7958,7 @@ function parseAgentMarkdown(filePath, content, agentDir) {
7915
7958
  };
7916
7959
  const result = AgentConfigSchema.safeParse(rawConfig);
7917
7960
  if (!result.success) {
7918
- console.error(`[remote-config] Invalid agent config in ${filePath}:`, result.error.format());
7961
+ logError(`Invalid agent config in ${filePath}: ${JSON.stringify(result.error.format())}`);
7919
7962
  return null;
7920
7963
  }
7921
7964
  return {
@@ -7938,14 +7981,14 @@ async function discoverCommands(repoPath) {
7938
7981
  const findCommands = (dir, depth) => {
7939
7982
  if (depth > DISCOVERY_LIMITS.maxDepth) {
7940
7983
  if (!limitsWarned) {
7941
- console.warn(`[remote-config] Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
7984
+ logWarn(`Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
7942
7985
  limitsWarned = true;
7943
7986
  }
7944
7987
  return;
7945
7988
  }
7946
7989
  if (filesProcessed >= DISCOVERY_LIMITS.maxFiles) {
7947
7990
  if (!limitsWarned) {
7948
- console.warn(`[remote-config] Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
7991
+ logWarn(`Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
7949
7992
  limitsWarned = true;
7950
7993
  }
7951
7994
  return;
@@ -7963,7 +8006,7 @@ async function discoverCommands(repoPath) {
7963
8006
  try {
7964
8007
  const stats = fs.statSync(fullPath);
7965
8008
  if (stats.size > DISCOVERY_LIMITS.maxFileSize) {
7966
- console.warn(`[remote-config] Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
8009
+ logWarn(`Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
7967
8010
  continue;
7968
8011
  }
7969
8012
  filesProcessed++;
@@ -7973,7 +8016,7 @@ async function discoverCommands(repoPath) {
7973
8016
  commands.push(parsed);
7974
8017
  }
7975
8018
  } catch (err) {
7976
- console.error(`[remote-config] Failed to parse command ${fullPath}:`, err);
8019
+ logError(`Failed to parse command ${fullPath}: ${err}`);
7977
8020
  }
7978
8021
  }
7979
8022
  }
@@ -7999,14 +8042,14 @@ function parseCommandMarkdown(filePath, content, commandDir) {
7999
8042
  });
8000
8043
  } catch (err) {
8001
8044
  const relativeToRepo = path.relative(path.dirname(commandDir), filePath);
8002
- console.error(`[remote-config] Failed to parse frontmatter in ${relativeToRepo}:`, err);
8045
+ logError(`Failed to parse frontmatter in ${relativeToRepo}: ${err}`);
8003
8046
  return null;
8004
8047
  }
8005
8048
  const relativePath = path.relative(commandDir, filePath).replace(/\\/g, "/");
8006
8049
  const commandName = relativePath.replace(/\.md$/i, "");
8007
8050
  if (!/^[a-zA-Z0-9_/-]+$/.test(commandName)) {
8008
8051
  const relativeToRepo = path.relative(path.dirname(commandDir), filePath);
8009
- console.warn(`[remote-config] Skipping command with invalid name characters: ${relativeToRepo}`);
8052
+ logWarn(`Skipping command with invalid name characters: ${relativeToRepo}`);
8010
8053
  return null;
8011
8054
  }
8012
8055
  const rawConfig = {
@@ -8015,7 +8058,7 @@ function parseCommandMarkdown(filePath, content, commandDir) {
8015
8058
  };
8016
8059
  const result = CommandConfigSchema.safeParse(rawConfig);
8017
8060
  if (!result.success) {
8018
- console.error(`[remote-config] Invalid command config in ${filePath}:`, result.error.format());
8061
+ logError(`Invalid command config in ${filePath}: ${JSON.stringify(result.error.format())}`);
8019
8062
  return null;
8020
8063
  }
8021
8064
  return {
@@ -8038,14 +8081,14 @@ async function discoverPlugins(repoPath, repoShortName) {
8038
8081
  const findPlugins = (dir, depth) => {
8039
8082
  if (depth > DISCOVERY_LIMITS.maxDepth) {
8040
8083
  if (!limitsWarned) {
8041
- console.warn(`[remote-config] Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
8084
+ logWarn(`Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
8042
8085
  limitsWarned = true;
8043
8086
  }
8044
8087
  return;
8045
8088
  }
8046
8089
  if (filesProcessed >= DISCOVERY_LIMITS.maxFiles) {
8047
8090
  if (!limitsWarned) {
8048
- console.warn(`[remote-config] Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
8091
+ logWarn(`Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
8049
8092
  limitsWarned = true;
8050
8093
  }
8051
8094
  return;
@@ -8063,7 +8106,7 @@ async function discoverPlugins(repoPath, repoShortName) {
8063
8106
  try {
8064
8107
  const stats = fs.statSync(fullPath);
8065
8108
  if (stats.size > DISCOVERY_LIMITS.maxFileSize) {
8066
- console.warn(`[remote-config] Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
8109
+ logWarn(`Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
8067
8110
  continue;
8068
8111
  }
8069
8112
  filesProcessed++;
@@ -8072,7 +8115,7 @@ async function discoverPlugins(repoPath, repoShortName) {
8072
8115
  const pluginName = relativePath.slice(0, -ext.length).replace(/\//g, "-");
8073
8116
  if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
8074
8117
  const relativeToRepo = path.relative(path.dirname(pluginDir), fullPath);
8075
- console.warn(`[remote-config] Skipping plugin with invalid name characters: ${relativeToRepo}`);
8118
+ logWarn(`Skipping plugin with invalid name characters: ${relativeToRepo}`);
8076
8119
  continue;
8077
8120
  }
8078
8121
  plugins.push({
@@ -8082,7 +8125,7 @@ async function discoverPlugins(repoPath, repoShortName) {
8082
8125
  extension: ext
8083
8126
  });
8084
8127
  } catch (err) {
8085
- console.error(`[remote-config] Failed to process plugin ${fullPath}:`, err);
8128
+ logError(`Failed to process plugin ${fullPath}: ${err}`);
8086
8129
  }
8087
8130
  }
8088
8131
  }
@@ -8197,20 +8240,122 @@ async function syncRepositories(configs) {
8197
8240
  return results;
8198
8241
  }
8199
8242
 
8200
- // src/symlinks.ts
8201
- import * as path2 from "path";
8202
- import * as fs2 from "fs";
8243
+ // src/install.ts
8244
+ import * as path3 from "path";
8245
+ import * as fs3 from "fs";
8246
+ var {$: $3 } = globalThis.Bun;
8247
+
8248
+ // src/copy.ts
8203
8249
  var {$: $2 } = globalThis.Bun;
8204
- var SKILL_BASE = path2.join(process.env.HOME || "~", ".config", "opencode", "skill");
8205
- var PLUGINS_DIR = path2.join(SKILL_BASE, "_plugins");
8206
- function getSymlinkPath(repoShortName, skillName) {
8207
- return path2.join(PLUGINS_DIR, repoShortName, skillName);
8250
+ import * as fs2 from "fs";
8251
+ import * as path2 from "path";
8252
+ var rsyncAvailable = null;
8253
+ async function detectRsync() {
8254
+ if (rsyncAvailable !== null) {
8255
+ return rsyncAvailable;
8256
+ }
8257
+ try {
8258
+ const result = await $2`which rsync`.quiet();
8259
+ rsyncAvailable = result.exitCode === 0;
8260
+ } catch {
8261
+ rsyncAvailable = false;
8262
+ }
8263
+ return rsyncAvailable;
8264
+ }
8265
+ async function copyWithRsync(source, target) {
8266
+ fs2.mkdirSync(path2.dirname(target), { recursive: true });
8267
+ const result = await $2`rsync -a --delete ${source}/ ${target}/`.quiet();
8268
+ if (result.exitCode !== 0) {
8269
+ throw new Error(`rsync failed: ${result.stderr.toString()}`);
8270
+ }
8271
+ }
8272
+ function copyWithNodeFs(source, target) {
8273
+ if (fs2.existsSync(target)) {
8274
+ const stat = fs2.lstatSync(target);
8275
+ if (stat.isSymbolicLink() || stat.isFile()) {
8276
+ fs2.unlinkSync(target);
8277
+ } else {
8278
+ fs2.rmSync(target, { recursive: true, force: true });
8279
+ }
8280
+ }
8281
+ fs2.mkdirSync(path2.dirname(target), { recursive: true });
8282
+ fs2.cpSync(source, target, { recursive: true });
8283
+ }
8284
+ async function syncDirectory(source, target) {
8285
+ let sourceStat;
8286
+ try {
8287
+ sourceStat = fs2.statSync(source);
8288
+ } catch {
8289
+ throw new Error(`Source does not exist: ${source}`);
8290
+ }
8291
+ if (!sourceStat.isDirectory()) {
8292
+ throw new Error(`Source is not a directory: ${source}`);
8293
+ }
8294
+ const resolvedSource = path2.resolve(source);
8295
+ const resolvedTarget = path2.resolve(target);
8296
+ if (resolvedTarget.startsWith(resolvedSource + path2.sep)) {
8297
+ throw new Error(`Target cannot be inside source: ${target} is inside ${source}`);
8298
+ }
8299
+ if (resolvedSource.startsWith(resolvedTarget + path2.sep)) {
8300
+ throw new Error(`Source cannot be inside target: ${source} is inside ${target}`);
8301
+ }
8302
+ const hasRsync = await detectRsync();
8303
+ if (hasRsync) {
8304
+ try {
8305
+ await copyWithRsync(source, target);
8306
+ logDebug(`Copied ${source} \u2192 ${target} using rsync`);
8307
+ return { method: "rsync" };
8308
+ } catch (err) {
8309
+ const errorMessage = err instanceof Error ? err.message : String(err);
8310
+ logError(`rsync failed, falling back to fs: ${errorMessage}`);
8311
+ }
8312
+ }
8313
+ try {
8314
+ copyWithNodeFs(source, target);
8315
+ logDebug(`Copied ${source} \u2192 ${target} using fs.cpSync`);
8316
+ return { method: "fs" };
8317
+ } catch (err) {
8318
+ try {
8319
+ if (fs2.existsSync(target)) {
8320
+ const stat = fs2.lstatSync(target);
8321
+ if (stat.isSymbolicLink() || stat.isFile()) {
8322
+ fs2.unlinkSync(target);
8323
+ } else {
8324
+ fs2.rmSync(target, { recursive: true, force: true });
8325
+ }
8326
+ }
8327
+ } catch {}
8328
+ throw err;
8329
+ }
8330
+ }
8331
+
8332
+ // src/install.ts
8333
+ var SKILL_BASE = path3.join(process.env.HOME || "~", ".config", "opencode", "skill");
8334
+ var PLUGINS_DIR = path3.join(SKILL_BASE, "_plugins");
8335
+ function getInstallPath(repoShortName, skillName) {
8336
+ return path3.join(PLUGINS_DIR, repoShortName, skillName);
8208
8337
  }
8209
8338
  function ensurePluginsDir() {
8210
- fs2.mkdirSync(PLUGINS_DIR, { recursive: true });
8339
+ fs3.mkdirSync(PLUGINS_DIR, { recursive: true });
8340
+ }
8341
+ function createSymlinkSync(sourcePath, targetPath) {
8342
+ if (fs3.existsSync(targetPath)) {
8343
+ const stats = fs3.lstatSync(targetPath);
8344
+ if (stats.isSymbolicLink()) {
8345
+ const existingTarget = fs3.readlinkSync(targetPath);
8346
+ if (existingTarget === sourcePath) {
8347
+ return { created: false };
8348
+ }
8349
+ fs3.unlinkSync(targetPath);
8350
+ } else {
8351
+ return { created: false, error: `Path exists and is not a symlink: ${targetPath}` };
8352
+ }
8353
+ }
8354
+ fs3.symlinkSync(sourcePath, targetPath, "dir");
8355
+ return { created: true };
8211
8356
  }
8212
- function createSkillSymlink(skill, repoShortName) {
8213
- const targetPath = getSymlinkPath(repoShortName, skill.name);
8357
+ async function createSkillInstall(skill, repoShortName, installMethod = "link") {
8358
+ const targetPath = getInstallPath(repoShortName, skill.name);
8214
8359
  const result = {
8215
8360
  skillName: skill.name,
8216
8361
  sourcePath: skill.path,
@@ -8218,78 +8363,81 @@ function createSkillSymlink(skill, repoShortName) {
8218
8363
  created: false
8219
8364
  };
8220
8365
  try {
8221
- fs2.mkdirSync(path2.dirname(targetPath), { recursive: true });
8222
- if (fs2.existsSync(targetPath)) {
8223
- const stats = fs2.lstatSync(targetPath);
8224
- if (stats.isSymbolicLink()) {
8225
- const existingTarget = fs2.readlinkSync(targetPath);
8226
- if (existingTarget === skill.path) {
8227
- return result;
8228
- }
8229
- fs2.unlinkSync(targetPath);
8230
- } else {
8231
- result.error = `Path exists and is not a symlink: ${targetPath}`;
8232
- return result;
8366
+ fs3.mkdirSync(path3.dirname(targetPath), { recursive: true });
8367
+ if (installMethod === "copy") {
8368
+ await syncDirectory(skill.path, targetPath);
8369
+ result.created = true;
8370
+ } else {
8371
+ const symlinkResult = createSymlinkSync(skill.path, targetPath);
8372
+ result.created = symlinkResult.created;
8373
+ if (symlinkResult.error) {
8374
+ result.error = symlinkResult.error;
8233
8375
  }
8234
8376
  }
8235
- fs2.symlinkSync(skill.path, targetPath, "dir");
8236
- result.created = true;
8237
8377
  } catch (err) {
8238
8378
  result.error = err instanceof Error ? err.message : String(err);
8239
8379
  }
8240
8380
  return result;
8241
8381
  }
8242
- function createSymlinksForRepo(syncResult) {
8382
+ async function createInstallsForRepo(syncResult, installMethod = "link") {
8243
8383
  ensurePluginsDir();
8244
8384
  const results = [];
8245
8385
  for (const skill of syncResult.skills) {
8246
- const result = createSkillSymlink(skill, syncResult.shortName);
8386
+ const result = await createSkillInstall(skill, syncResult.shortName, installMethod);
8247
8387
  results.push(result);
8248
8388
  }
8249
8389
  return results;
8250
8390
  }
8251
- function getExistingSymlinks() {
8252
- const symlinks = new Map;
8253
- if (!fs2.existsSync(PLUGINS_DIR)) {
8254
- return symlinks;
8255
- }
8256
- const scanDir = (dir, prefix = "") => {
8257
- const entries = fs2.readdirSync(dir, { withFileTypes: true });
8258
- for (const entry of entries) {
8259
- if (entry.name.startsWith("."))
8391
+ function getExistingInstalls() {
8392
+ const installs = new Map;
8393
+ if (!fs3.existsSync(PLUGINS_DIR)) {
8394
+ return installs;
8395
+ }
8396
+ const repoEntries = fs3.readdirSync(PLUGINS_DIR, { withFileTypes: true });
8397
+ for (const repoEntry of repoEntries) {
8398
+ if (repoEntry.name.startsWith(".") || !repoEntry.isDirectory())
8399
+ continue;
8400
+ const repoPath = path3.join(PLUGINS_DIR, repoEntry.name);
8401
+ const skillEntries = fs3.readdirSync(repoPath, { withFileTypes: true });
8402
+ for (const skillEntry of skillEntries) {
8403
+ if (skillEntry.name.startsWith("."))
8260
8404
  continue;
8261
- const fullPath = path2.join(dir, entry.name);
8262
- const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
8263
- if (entry.isSymbolicLink()) {
8264
- const target = fs2.readlinkSync(fullPath);
8265
- symlinks.set(relativePath, target);
8266
- } else if (entry.isDirectory()) {
8267
- scanDir(fullPath, relativePath);
8405
+ const fullPath = path3.join(repoPath, skillEntry.name);
8406
+ const relativePath = `${repoEntry.name}/${skillEntry.name}`;
8407
+ if (skillEntry.isSymbolicLink()) {
8408
+ const target = fs3.readlinkSync(fullPath);
8409
+ installs.set(relativePath, target);
8410
+ } else if (skillEntry.isDirectory()) {
8411
+ installs.set(relativePath, fullPath);
8268
8412
  }
8269
8413
  }
8270
- };
8271
- scanDir(PLUGINS_DIR);
8272
- return symlinks;
8414
+ }
8415
+ return installs;
8273
8416
  }
8274
- function cleanupStaleSymlinks(currentSkills) {
8417
+ function cleanupStaleInstalls(currentSkills) {
8275
8418
  const result = {
8276
8419
  removed: [],
8277
8420
  errors: []
8278
8421
  };
8279
- const existingSymlinks = getExistingSymlinks();
8280
- for (const [relativePath] of existingSymlinks) {
8422
+ const existingInstalls = getExistingInstalls();
8423
+ for (const [relativePath] of existingInstalls) {
8281
8424
  if (!currentSkills.has(relativePath)) {
8282
- const fullPath = path2.join(PLUGINS_DIR, relativePath);
8425
+ const fullPath = path3.join(PLUGINS_DIR, relativePath);
8283
8426
  try {
8284
- fs2.unlinkSync(fullPath);
8427
+ const stats = fs3.lstatSync(fullPath);
8428
+ if (stats.isSymbolicLink()) {
8429
+ fs3.unlinkSync(fullPath);
8430
+ } else if (stats.isDirectory()) {
8431
+ fs3.rmSync(fullPath, { recursive: true, force: true });
8432
+ }
8285
8433
  result.removed.push(relativePath);
8286
- let parentDir = path2.dirname(fullPath);
8434
+ let parentDir = path3.dirname(fullPath);
8287
8435
  while (parentDir !== PLUGINS_DIR && parentDir.startsWith(PLUGINS_DIR)) {
8288
8436
  try {
8289
- const entries = fs2.readdirSync(parentDir);
8437
+ const entries = fs3.readdirSync(parentDir);
8290
8438
  if (entries.length === 0) {
8291
- fs2.rmdirSync(parentDir);
8292
- parentDir = path2.dirname(parentDir);
8439
+ fs3.rmdirSync(parentDir);
8440
+ parentDir = path3.dirname(parentDir);
8293
8441
  } else {
8294
8442
  break;
8295
8443
  }
@@ -8305,16 +8453,16 @@ function cleanupStaleSymlinks(currentSkills) {
8305
8453
  return result;
8306
8454
  }
8307
8455
  function hasLocalConflict(skillName) {
8308
- const localSkillPath = path2.join(SKILL_BASE, skillName);
8309
- if (fs2.existsSync(localSkillPath)) {
8310
- const realPath = fs2.realpathSync(localSkillPath);
8456
+ const localSkillPath = path3.join(SKILL_BASE, skillName);
8457
+ if (fs3.existsSync(localSkillPath)) {
8458
+ const realPath = fs3.realpathSync(localSkillPath);
8311
8459
  return !realPath.includes("_plugins");
8312
8460
  }
8313
8461
  return false;
8314
8462
  }
8315
8463
  async function findGitRoot(startPath) {
8316
8464
  try {
8317
- const result = await $2`git -C ${startPath} rev-parse --show-toplevel`.quiet();
8465
+ const result = await $3`git -C ${startPath} rev-parse --show-toplevel`.quiet();
8318
8466
  if (result.exitCode === 0) {
8319
8467
  return result.stdout.toString().trim();
8320
8468
  }
@@ -8326,14 +8474,14 @@ async function ensureGitignore() {
8326
8474
  if (!gitRoot) {
8327
8475
  return { modified: false, gitRoot: null };
8328
8476
  }
8329
- const gitignorePath = path2.join(gitRoot, ".gitignore");
8477
+ const gitignorePath = path3.join(gitRoot, ".gitignore");
8330
8478
  const pluginsEntry = "_plugins/";
8331
- const relativePath = path2.relative(gitRoot, PLUGINS_DIR);
8479
+ const relativePath = path3.relative(gitRoot, PLUGINS_DIR);
8332
8480
  const gitignoreEntry = relativePath ? `${relativePath}/` : pluginsEntry;
8333
8481
  let content = "";
8334
8482
  let exists = false;
8335
8483
  try {
8336
- content = fs2.readFileSync(gitignorePath, "utf-8");
8484
+ content = fs3.readFileSync(gitignorePath, "utf-8");
8337
8485
  exists = true;
8338
8486
  } catch {}
8339
8487
  const lines = content.split(`
@@ -8355,27 +8503,41 @@ ${gitignoreEntry}
8355
8503
  ` : `# OpenCode remote skills plugin
8356
8504
  ${gitignoreEntry}
8357
8505
  `;
8358
- fs2.writeFileSync(gitignorePath, newContent);
8506
+ fs3.writeFileSync(gitignorePath, newContent);
8359
8507
  return { modified: true, gitRoot };
8360
8508
  }
8361
8509
 
8362
- // src/plugin-symlinks.ts
8363
- import * as fs3 from "fs";
8364
- import * as path3 from "path";
8365
- import { homedir as homedir2 } from "os";
8366
- var DEFAULT_PLUGIN_DIR = path3.join(homedir2(), ".config", "opencode", "plugin");
8510
+ // src/plugin-install.ts
8511
+ import * as fs4 from "fs";
8512
+ import * as path4 from "path";
8513
+ import { homedir as homedir3 } from "os";
8514
+ var DEFAULT_PLUGIN_DIR = path4.join(homedir3(), ".config", "opencode", "plugin");
8367
8515
  var REMOTE_PREFIX = "_remote_";
8516
+ function removePathIfExists(targetPath) {
8517
+ try {
8518
+ const stats = fs4.lstatSync(targetPath);
8519
+ if (stats.isDirectory()) {
8520
+ fs4.rmSync(targetPath, { recursive: true });
8521
+ } else {
8522
+ fs4.unlinkSync(targetPath);
8523
+ }
8524
+ } catch (err) {
8525
+ if (err.code !== "ENOENT") {
8526
+ throw err;
8527
+ }
8528
+ }
8529
+ }
8368
8530
  function getPluginSymlinkName(plugin) {
8369
8531
  return `${REMOTE_PREFIX}${plugin.repoShortName}_${plugin.name}${plugin.extension}`;
8370
8532
  }
8371
8533
  function ensurePluginDir(pluginDir = DEFAULT_PLUGIN_DIR) {
8372
- if (!fs3.existsSync(pluginDir)) {
8373
- fs3.mkdirSync(pluginDir, { recursive: true });
8534
+ if (!fs4.existsSync(pluginDir)) {
8535
+ fs4.mkdirSync(pluginDir, { recursive: true });
8374
8536
  }
8375
8537
  }
8376
- function createPluginSymlink(plugin, pluginDir = DEFAULT_PLUGIN_DIR) {
8538
+ function createPluginInstall(plugin, pluginDir = DEFAULT_PLUGIN_DIR, installMethod = "link") {
8377
8539
  const symlinkName = getPluginSymlinkName(plugin);
8378
- const symlinkPath = path3.join(pluginDir, symlinkName);
8540
+ const symlinkPath = path4.join(pluginDir, symlinkName);
8379
8541
  const result = {
8380
8542
  pluginName: plugin.name,
8381
8543
  symlinkName,
@@ -8384,45 +8546,42 @@ function createPluginSymlink(plugin, pluginDir = DEFAULT_PLUGIN_DIR) {
8384
8546
  };
8385
8547
  try {
8386
8548
  ensurePluginDir(pluginDir);
8387
- try {
8388
- fs3.lstatSync(symlinkPath);
8389
- fs3.unlinkSync(symlinkPath);
8390
- } catch (err) {
8391
- if (err.code !== "ENOENT") {
8392
- throw err;
8393
- }
8549
+ removePathIfExists(symlinkPath);
8550
+ if (installMethod === "copy") {
8551
+ fs4.cpSync(plugin.path, symlinkPath);
8552
+ } else {
8553
+ fs4.symlinkSync(plugin.path, symlinkPath);
8394
8554
  }
8395
- fs3.symlinkSync(plugin.path, symlinkPath);
8396
8555
  } catch (err) {
8397
8556
  result.error = err instanceof Error ? err.message : String(err);
8398
8557
  }
8399
8558
  return result;
8400
8559
  }
8401
- function createPluginSymlinks(plugins, pluginDir = DEFAULT_PLUGIN_DIR) {
8402
- return plugins.map((p) => createPluginSymlink(p, pluginDir));
8560
+ function createPluginInstalls(plugins, pluginDir = DEFAULT_PLUGIN_DIR, installMethod = "link") {
8561
+ return plugins.map((plugin) => createPluginInstall(plugin, pluginDir, installMethod));
8403
8562
  }
8404
- function getRemotePluginSymlinks(pluginDir = DEFAULT_PLUGIN_DIR) {
8405
- if (!fs3.existsSync(pluginDir)) {
8563
+ function getRemotePluginInstalls(pluginDir = DEFAULT_PLUGIN_DIR) {
8564
+ if (!fs4.existsSync(pluginDir)) {
8406
8565
  return [];
8407
8566
  }
8408
8567
  try {
8409
- const entries = fs3.readdirSync(pluginDir);
8568
+ const entries = fs4.readdirSync(pluginDir);
8410
8569
  return entries.filter((name) => name.startsWith(REMOTE_PREFIX));
8411
8570
  } catch {
8412
8571
  return [];
8413
8572
  }
8414
8573
  }
8415
- function cleanupStalePluginSymlinks(currentSymlinks, pluginDir = DEFAULT_PLUGIN_DIR) {
8574
+ function cleanupStalePluginInstalls(currentInstalls, pluginDir = DEFAULT_PLUGIN_DIR) {
8416
8575
  const result = {
8417
8576
  removed: [],
8418
8577
  errors: []
8419
8578
  };
8420
- const existing = getRemotePluginSymlinks(pluginDir);
8579
+ const existing = getRemotePluginInstalls(pluginDir);
8421
8580
  for (const name of existing) {
8422
- if (!currentSymlinks.has(name)) {
8423
- const symlinkPath = path3.join(pluginDir, name);
8581
+ if (!currentInstalls.has(name)) {
8582
+ const installPath = path4.join(pluginDir, name);
8424
8583
  try {
8425
- fs3.unlinkSync(symlinkPath);
8584
+ removePathIfExists(installPath);
8426
8585
  result.removed.push(name);
8427
8586
  } catch (err) {
8428
8587
  result.errors.push(`Failed to remove ${name}: ${err instanceof Error ? err.message : String(err)}`);
@@ -8433,33 +8592,7 @@ function cleanupStalePluginSymlinks(currentSymlinks, pluginDir = DEFAULT_PLUGIN_
8433
8592
  }
8434
8593
 
8435
8594
  // src/index.ts
8436
- import { appendFileSync, mkdirSync as mkdirSync4 } from "fs";
8437
- import { join as join7 } from "path";
8438
- import { homedir as homedir3 } from "os";
8439
- var LOG_PREFIX = "[remote-config]";
8440
- var LOG_DIR = join7(homedir3(), ".cache", "opencode", "remote-config");
8441
- var LOG_FILE = join7(LOG_DIR, "plugin.log");
8442
8595
  var initialized = false;
8443
- function timestamp() {
8444
- return new Date().toISOString();
8445
- }
8446
- function writeLog(level, message) {
8447
- try {
8448
- mkdirSync4(LOG_DIR, { recursive: true });
8449
- appendFileSync(LOG_FILE, `${timestamp()} [${level}] ${message}
8450
- `);
8451
- } catch {}
8452
- }
8453
- function log(message) {
8454
- const fullMessage = `${LOG_PREFIX} ${message}`;
8455
- console.log(fullMessage);
8456
- writeLog("INFO", message);
8457
- }
8458
- function logError(message) {
8459
- const fullMessage = `${LOG_PREFIX} ${message}`;
8460
- console.error(fullMessage);
8461
- writeLog("ERROR", message);
8462
- }
8463
8596
  async function performSync(config) {
8464
8597
  if (config.repositories.length === 0) {
8465
8598
  return { results: [], skippedConflicts: [], totalSkills: 0, remoteAgents: new Map, remoteCommands: new Map, remoteInstructions: [], pluginsChanged: false, totalPlugins: 0 };
@@ -8474,30 +8607,30 @@ async function performSync(config) {
8474
8607
  logError(`\u2717 Failed to sync ${result.shortName}: ${result.error}`);
8475
8608
  continue;
8476
8609
  }
8477
- const skillsToLink = result.skills.filter((skill) => {
8610
+ const skillsToInstall = result.skills.filter((skill) => {
8478
8611
  if (hasLocalConflict(skill.name)) {
8479
8612
  skippedConflicts.push(skill.name);
8480
8613
  return false;
8481
8614
  }
8482
8615
  return true;
8483
8616
  });
8484
- const filteredResult = { ...result, skills: skillsToLink };
8485
- const symlinkResults = createSymlinksForRepo(filteredResult);
8486
- for (const sr of symlinkResults) {
8617
+ const filteredResult = { ...result, skills: skillsToInstall };
8618
+ const installResults = await createInstallsForRepo(filteredResult, config.installMethod);
8619
+ for (const sr of installResults) {
8487
8620
  if (!sr.error) {
8488
8621
  currentSkills.add(`${result.shortName}/${sr.skillName}`);
8489
8622
  totalSkills++;
8490
8623
  } else {
8491
- logError(`\u2717 Failed to create symlink for ${sr.skillName}: ${sr.error}`);
8624
+ logError(`\u2717 Failed to install skill ${sr.skillName}: ${sr.error}`);
8492
8625
  }
8493
8626
  }
8494
- const skillCount = skillsToLink.length;
8627
+ const skillCount = skillsToInstall.length;
8495
8628
  const status = result.updated ? "\u2713" : "\u2713";
8496
8629
  log(`${status} ${result.shortName} (${result.ref}) - ${skillCount} skills`);
8497
8630
  }
8498
- const cleanup = cleanupStaleSymlinks(currentSkills);
8631
+ const cleanup = cleanupStaleInstalls(currentSkills);
8499
8632
  if (cleanup.removed.length > 0) {
8500
- log(`Cleaned up ${cleanup.removed.length} stale symlinks`);
8633
+ log(`Cleaned up ${cleanup.removed.length} stale skill installs`);
8501
8634
  }
8502
8635
  for (const conflict of skippedConflicts) {
8503
8636
  log(`\u26A0 Conflict: '${conflict}' exists locally, skipping`);
@@ -8557,25 +8690,25 @@ async function performSync(config) {
8557
8690
  continue;
8558
8691
  allPlugins.push(...result.plugins);
8559
8692
  }
8560
- const existingPluginSymlinks = new Set(getRemotePluginSymlinks());
8561
- const newPluginSymlinks = new Set;
8693
+ const existingPluginInstalls = new Set(getRemotePluginInstalls());
8694
+ const newPluginInstalls = new Set;
8562
8695
  if (allPlugins.length > 0) {
8563
- const symlinkResults = createPluginSymlinks(allPlugins);
8564
- for (const sr of symlinkResults) {
8696
+ const installResults = createPluginInstalls(allPlugins, undefined, config.installMethod);
8697
+ for (const sr of installResults) {
8565
8698
  if (!sr.error) {
8566
- newPluginSymlinks.add(sr.symlinkName);
8699
+ newPluginInstalls.add(sr.symlinkName);
8567
8700
  } else {
8568
- logError(`\u2717 Failed to create plugin symlink for ${sr.pluginName}: ${sr.error}`);
8701
+ logError(`\u2717 Failed to install plugin ${sr.pluginName}: ${sr.error}`);
8569
8702
  }
8570
8703
  }
8571
8704
  log(`Discovered ${allPlugins.length} remote plugins`);
8572
8705
  }
8573
- const pluginCleanup = cleanupStalePluginSymlinks(newPluginSymlinks);
8706
+ const pluginCleanup = cleanupStalePluginInstalls(newPluginInstalls);
8574
8707
  if (pluginCleanup.removed.length > 0) {
8575
- log(`Cleaned up ${pluginCleanup.removed.length} stale plugin symlinks`);
8708
+ log(`Cleaned up ${pluginCleanup.removed.length} stale plugin installs`);
8576
8709
  }
8577
- const pluginsChanged = !setsEqual(existingPluginSymlinks, newPluginSymlinks);
8578
- const totalPlugins = newPluginSymlinks.size;
8710
+ const pluginsChanged = !setsEqual(existingPluginInstalls, newPluginInstalls);
8711
+ const totalPlugins = newPluginInstalls.size;
8579
8712
  return { results, skippedConflicts, totalSkills, remoteAgents, remoteCommands, remoteInstructions, pluginsChanged, totalPlugins };
8580
8713
  }
8581
8714
  function setsEqual(a, b) {
@@ -8596,6 +8729,11 @@ var RemoteSkillsPlugin = async (ctx) => {
8596
8729
  if (pluginConfig.repositories.length === 0) {
8597
8730
  return {};
8598
8731
  }
8732
+ if (pluginConfig.installMethod === "copy") {
8733
+ logDebug("Using copy mode for skill/plugin installation");
8734
+ } else {
8735
+ logDebug("Using symlink mode for skill/plugin installation");
8736
+ }
8599
8737
  ensurePluginsDir();
8600
8738
  const gitignoreResult = await ensureGitignore();
8601
8739
  if (gitignoreResult.modified) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgordijn/opencode-remote-config",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCode plugin to sync skills, agents and commands from Git repositories",
5
5
  "repository": {
6
6
  "type": "git",