@jgordijn/opencode-remote-config 0.1.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 +89 -26
  2. package/dist/index.js +461 -196
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # opencode-remote-config
2
2
 
3
- An OpenCode plugin that syncs skills and agents from Git repositories, making them available to OpenCode without polluting your local configuration.
3
+ An OpenCode plugin that syncs skills, agents, commands, and instructions from Git repositories, making them available to OpenCode without polluting your local configuration.
4
4
 
5
5
  ## Features
6
6
 
@@ -8,8 +8,10 @@ An OpenCode plugin that syncs skills and agents from Git repositories, making th
8
8
  - **Local directories**: Use `file://` URLs for local directories (great for development)
9
9
  - **Skills import**: Import skill definitions from `skill/` directory
10
10
  - **Agents import**: Import agent definitions from `agent/` directory
11
+ - **Commands import**: Import slash commands from `command/` directory
11
12
  - **Plugins import**: Import OpenCode hook plugins from `plugin/` directory
12
- - **Selective import**: Import all or specify which skills/agents/plugins to include
13
+ - **Instructions import**: Import AGENTS.md instructions via manifest.json
14
+ - **Selective import**: Use `include` or `exclude` filters for fine-grained control
13
15
  - **Ref pinning**: Pin to branch, tag, or commit SHA
14
16
  - **Priority handling**: User config > first repository > subsequent repositories
15
17
  - **Conflict handling**: Local definitions take precedence, warns on conflicts
@@ -71,9 +73,9 @@ bun install && bun run build
71
73
  {
72
74
  "url": "git@github.com:company/shared-skills.git",
73
75
  "ref": "main",
74
- "skills": ["code-review", "kotlin-pro"],
75
- "agents": ["code-reviewer", "specialized/db-expert"],
76
- "plugins": ["notify", "utils-logger"]
76
+ "skills": { "include": ["code-review", "kotlin-pro"] },
77
+ "agents": { "include": ["code-reviewer", "specialized/db-expert"] },
78
+ "plugins": { "include": ["notify", "utils-logger"] }
77
79
  },
78
80
  {
79
81
  "url": "git@github.com:team/team-skills.git",
@@ -83,7 +85,7 @@ bun install && bun run build
83
85
  }
84
86
  ```
85
87
 
86
- 3. **Restart OpenCode** to load the plugin.
88
+ **Restart OpenCode** to load the plugin.
87
89
 
88
90
  ### Configuration
89
91
 
@@ -98,12 +100,59 @@ The plugin reads its configuration from a separate JSON file (not `opencode.json
98
100
 
99
101
  | Option | Type | Default | Description |
100
102
  |--------|------|---------|-------------|
103
+ | `installMethod` | `"link"` or `"copy"` | `"link"` | How to install skills/plugins (symlinks or copies) |
101
104
  | `repositories` | Array | `[]` | List of repositories to sync |
102
105
  | `repositories[].url` | String | Required | Git URL, HTTPS URL, or `file://` path |
103
106
  | `repositories[].ref` | String | Default branch | Branch, tag, or commit SHA (git only) |
104
- | `repositories[].skills` | Array or `"*"` | All skills | Specific skills to import |
105
- | `repositories[].agents` | Array or `"*"` | All agents | Specific agents to import |
106
- | `repositories[].plugins` | Array or `"*"` | All plugins | Specific plugins to import |
107
+ | `repositories[].skills` | `"*"` or `{ include: [...] }` or `{ exclude: [...] }` | All skills | Skills to import |
108
+ | `repositories[].agents` | `"*"` or `{ include: [...] }` or `{ exclude: [...] }` | All agents | Agents to import |
109
+ | `repositories[].commands` | `"*"` or `{ include: [...] }` or `{ exclude: [...] }` | All commands | Slash commands to import |
110
+ | `repositories[].plugins` | `"*"` or `{ include: [...] }` or `{ exclude: [...] }` | All plugins | Plugins to import |
111
+ | `repositories[].instructions` | `"*"` or `{ include: [...] }` or `{ exclude: [...] }` | All instructions | Instructions from manifest to import |
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
107
156
 
108
157
  ### Local Directories
109
158
 
@@ -129,7 +178,7 @@ For development or local skill repositories, use `file://` URLs:
129
178
  Skills are cloned to a cache directory and symlinked into the OpenCode skill directory:
130
179
 
131
180
  ```
132
- ~/.cache/opencode/remote-skills/repos/
181
+ ~/.cache/opencode/remote-config/repos/
133
182
  └── github.com-company-shared-skills/
134
183
  └── <full git clone>
135
184
 
@@ -200,20 +249,20 @@ Nested paths are converted to dashes: `plugin/foo/bar/baz.ts` becomes `foo-bar-b
200
249
 
201
250
  **Plugin changes require restart:** Unlike skills, plugins are loaded at OpenCode startup. When plugin changes are detected, you'll see:
202
251
  ```
203
- [remote-skills] Plugin changes detected. Restart OpenCode to apply.
252
+ [remote-config] Plugin changes detected. Restart OpenCode to apply.
204
253
  ```
205
254
 
206
255
  ### Example Output
207
256
 
208
257
  ```
209
- [remote-skills] Syncing 2 repositories...
210
- [remote-skills] ✓ company/shared-skills (main) - 5 skills
211
- [remote-skills] ✓ team/team-skills (v1.2.0) - 3 skills
212
- [remote-skills] ⚠ Conflict: 'git-commit' exists locally, skipping
213
- [remote-skills] Discovered 4 remote agents
214
- [remote-skills] Discovered 2 remote plugins
215
- [remote-skills] 7 skills, 2 plugins available
216
- [remote-skills] Injected 4 remote agents into config
258
+ [remote-config] Syncing 2 repositories...
259
+ [remote-config] ✓ company/shared-skills (main) - 5 skills
260
+ [remote-config] ✓ team/team-skills (v1.2.0) - 3 skills
261
+ [remote-config] ⚠ Conflict: 'git-commit' exists locally, skipping
262
+ [remote-config] Discovered 4 remote agents
263
+ [remote-config] Discovered 2 remote plugins
264
+ [remote-config] 7 skills, 2 plugins available
265
+ [remote-config] Injected 4 remote agents into config
217
266
  ```
218
267
 
219
268
  ### Authentication
@@ -305,7 +354,7 @@ This section contains instructions for LLM agents helping users set up the openc
305
354
  **Problem: Updates not reflected**
306
355
  - In background mode, restart OpenCode to apply updates
307
356
  - Check that the `ref` (branch/tag) is correct
308
- - Try removing the cached repo: `rm -rf ~/.cache/opencode/remote-skills/repos/<repo-id>`
357
+ - Try removing the cached repo: `rm -rf ~/.cache/opencode/remote-config/repos/<repo-id>`
309
358
 
310
359
  ### Repository Structure Requirements
311
360
 
@@ -412,8 +461,8 @@ import * as fs from "fs"
412
461
  {
413
462
  "url": "git@github.com:company/shared-skills.git",
414
463
  "ref": "main",
415
- "skills": ["code-review", "testing"],
416
- "agents": ["code-reviewer"]
464
+ "skills": { "include": ["code-review", "testing"] },
465
+ "agents": { "include": ["code-reviewer"] }
417
466
  },
418
467
  {
419
468
  "url": "git@github.com:team/team-skills.git",
@@ -424,13 +473,27 @@ import * as fs from "fs"
424
473
  }
425
474
  ```
426
475
 
427
- **Skills only (no agents):**
476
+ **Skills only (exclude all agents):**
477
+ ```jsonc
478
+ {
479
+ "repositories": [
480
+ {
481
+ "url": "git@github.com:company/skills.git",
482
+ "agents": { "include": ["__none__"] } // Include a non-existent name to import no agents
483
+ }
484
+ ]
485
+ }
486
+ ```
487
+
488
+ Note: To effectively import nothing, include a name that doesn't exist in the repository. The include/exclude arrays require at least one item.
489
+
490
+ **Exclude specific items:**
428
491
  ```jsonc
429
492
  {
430
493
  "repositories": [
431
494
  {
432
495
  "url": "git@github.com:company/skills.git",
433
- "agents": [] // Empty array imports no agents
496
+ "skills": { "exclude": ["deprecated-skill", "experimental"] }
434
497
  }
435
498
  ]
436
499
  }
@@ -443,8 +506,8 @@ import * as fs from "fs"
443
506
  {
444
507
  "url": "git@github.com:company/shared-skills.git",
445
508
  "skills": "*",
446
- "agents": ["code-reviewer"],
447
- "plugins": ["notify", "analytics"]
509
+ "agents": { "include": ["code-reviewer"] },
510
+ "plugins": { "include": ["notify", "analytics"] }
448
511
  }
449
512
  ]
450
513
  }
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({
@@ -7496,21 +7537,36 @@ var RepositoryConfigSchema = exports_external.object({
7496
7537
  skills: ImportConfigSchema.optional(),
7497
7538
  agents: ImportConfigSchema.optional(),
7498
7539
  commands: ImportConfigSchema.optional(),
7499
- plugins: ImportConfigSchema.optional()
7540
+ plugins: ImportConfigSchema.optional(),
7541
+ instructions: ImportConfigSchema.optional()
7500
7542
  }).strict();
7543
+ function shouldImport(name, config) {
7544
+ if (config === undefined || config === "*") {
7545
+ return true;
7546
+ }
7547
+ if ("include" in config) {
7548
+ return config.include.includes(name);
7549
+ }
7550
+ if ("exclude" in config) {
7551
+ return !config.exclude.includes(name);
7552
+ }
7553
+ return true;
7554
+ }
7501
7555
  var RemoteSkillsConfigSchema = exports_external.object({
7502
7556
  $schema: exports_external.string().optional(),
7503
7557
  repositories: exports_external.array(RepositoryConfigSchema).default([]),
7504
- 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")
7505
7560
  }).strict();
7506
7561
  var DEFAULT_CONFIG = {
7507
7562
  repositories: [],
7508
- sync: "blocking"
7563
+ sync: "blocking",
7564
+ installMethod: "link"
7509
7565
  };
7510
7566
  function getConfigPaths() {
7511
7567
  return [
7512
- join(process.cwd(), ".opencode", CONFIG_FILENAME),
7513
- join(homedir(), ".config", "opencode", CONFIG_FILENAME)
7568
+ join2(process.cwd(), ".opencode", CONFIG_FILENAME),
7569
+ join2(homedir2(), ".config", "opencode", CONFIG_FILENAME)
7514
7570
  ];
7515
7571
  }
7516
7572
  function loadConfig() {
@@ -7525,13 +7581,13 @@ function loadConfigWithLocation() {
7525
7581
  const parsed = JSON.parse(content);
7526
7582
  const result = RemoteSkillsConfigSchema.safeParse(parsed);
7527
7583
  if (!result.success) {
7528
- console.error(`[remote-skills] Invalid configuration in ${configPath}:`, result.error.format());
7584
+ logError(`Invalid configuration in ${configPath}: ${JSON.stringify(result.error.format())}`);
7529
7585
  continue;
7530
7586
  }
7531
- const configDir = join(configPath, "..");
7587
+ const configDir = join2(configPath, "..");
7532
7588
  return { config: result.data, configDir };
7533
7589
  } catch (error) {
7534
- console.error(`[remote-skills] Error reading ${configPath}:`, error);
7590
+ logError(`Error reading ${configPath}: ${error}`);
7535
7591
  continue;
7536
7592
  }
7537
7593
  }
@@ -7596,8 +7652,95 @@ var CommandConfigSchema = exports_external.object({
7596
7652
  subtask: exports_external.boolean().optional()
7597
7653
  }).passthrough();
7598
7654
 
7655
+ // src/instruction.ts
7656
+ import { existsSync as existsSync3 } from "fs";
7657
+ import { join as join4, normalize, resolve, sep } from "path";
7658
+
7659
+ // src/manifest.ts
7660
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
7661
+ import { join as join3, isAbsolute } from "path";
7662
+ var MANIFEST_FILENAME = "manifest.json";
7663
+ function containsPathTraversal(path) {
7664
+ return path.split("/").some((segment) => segment === ".." || segment === ".");
7665
+ }
7666
+ var instructionPathSchema = exports_external.string().transform((path) => path.trim()).transform((path) => path.startsWith("./") ? path.slice(2) : path).refine((path) => path.length > 0, { message: "Instruction path must not be empty" }).refine((path) => path.endsWith(".md"), { message: "Instruction path must end with .md" }).refine((path) => {
7667
+ const filename = path.includes("/") ? path.split("/").pop() : path;
7668
+ return filename.length > 3;
7669
+ }, { message: "Instruction path must have a filename before .md extension" }).refine((path) => !isAbsolute(path), { message: "Instruction path must be relative (absolute paths not allowed)" }).refine((path) => !path.includes("\\"), { message: "Instruction path must use forward slashes (POSIX-style)" }).refine((path) => !containsPathTraversal(path), { message: "Instruction path must not contain path traversal (. or ..)" }).refine((path) => !path.includes("//"), { message: "Instruction path must not contain consecutive slashes" }).refine((path) => !path.endsWith("/"), { message: "Instruction path must not have a trailing slash" });
7670
+ var ManifestSchema = exports_external.object({
7671
+ $schema: exports_external.string().optional(),
7672
+ instructions: exports_external.array(instructionPathSchema).optional().default([])
7673
+ });
7674
+ function loadManifest(repoPath) {
7675
+ const manifestPath = join3(repoPath, MANIFEST_FILENAME);
7676
+ if (!existsSync2(manifestPath)) {
7677
+ return { status: "not-found" };
7678
+ }
7679
+ try {
7680
+ const content = readFileSync2(manifestPath, "utf-8");
7681
+ const parsed = JSON.parse(content);
7682
+ const result = ManifestSchema.safeParse(parsed);
7683
+ if (!result.success) {
7684
+ const errorMessage = JSON.stringify(result.error.format());
7685
+ console.warn(`[remote-config] Invalid manifest.json in ${repoPath}:`, result.error.format());
7686
+ return { status: "invalid", error: errorMessage };
7687
+ }
7688
+ return { status: "found", manifest: result.data };
7689
+ } catch (error) {
7690
+ const errorMessage = error instanceof Error ? error.message : String(error);
7691
+ console.warn(`[remote-config] Error reading manifest.json in ${repoPath}:`, error);
7692
+ return { status: "invalid", error: errorMessage };
7693
+ }
7694
+ }
7695
+
7696
+ // src/instruction.ts
7697
+ var INSTRUCTION_LIMITS = {
7698
+ maxInstructions: 100,
7699
+ maxPathLength: 500
7700
+ };
7701
+ function discoverInstructions(repoPath) {
7702
+ const result = loadManifest(repoPath);
7703
+ if (result.status === "not-found") {
7704
+ return [];
7705
+ }
7706
+ if (result.status === "invalid") {
7707
+ console.warn(`[remote-config] Skipping instructions for ${repoPath}: invalid manifest.json`);
7708
+ return [];
7709
+ }
7710
+ const manifest = result.manifest;
7711
+ const instructions = [];
7712
+ const instructionsToProcess = manifest.instructions.slice(0, INSTRUCTION_LIMITS.maxInstructions);
7713
+ if (manifest.instructions.length > INSTRUCTION_LIMITS.maxInstructions) {
7714
+ console.warn(`[remote-config] Limiting instructions to ${INSTRUCTION_LIMITS.maxInstructions} (manifest has ${manifest.instructions.length})`);
7715
+ }
7716
+ for (const instructionName of instructionsToProcess) {
7717
+ if (instructionName.length > INSTRUCTION_LIMITS.maxPathLength) {
7718
+ console.warn(`[remote-config] Skipping instruction with path exceeding ${INSTRUCTION_LIMITS.maxPathLength} chars`);
7719
+ continue;
7720
+ }
7721
+ if (containsPathTraversal(instructionName)) {
7722
+ console.warn(`[remote-config] Skipping instruction with path traversal: ${instructionName}`);
7723
+ continue;
7724
+ }
7725
+ const absolutePath = join4(repoPath, instructionName);
7726
+ const resolvedPath = normalize(resolve(absolutePath));
7727
+ const resolvedRepoPath = normalize(resolve(repoPath));
7728
+ if (!resolvedPath.startsWith(resolvedRepoPath + sep)) {
7729
+ console.warn(`[remote-config] Skipping instruction outside repository: ${instructionName}`);
7730
+ continue;
7731
+ }
7732
+ if (existsSync3(absolutePath)) {
7733
+ instructions.push({
7734
+ name: instructionName,
7735
+ path: absolutePath
7736
+ });
7737
+ }
7738
+ }
7739
+ return instructions;
7740
+ }
7741
+
7599
7742
  // src/git.ts
7600
- var CACHE_BASE = path.join(process.env.HOME || "~", ".cache", "opencode", "remote-skills", "repos");
7743
+ var CACHE_BASE = path.join(process.env.HOME || "~", ".cache", "opencode", "remote-config", "repos");
7601
7744
  function isFileUrl(url) {
7602
7745
  return url.startsWith("file://");
7603
7746
  }
@@ -7617,7 +7760,10 @@ async function cloneRepo(url, repoPath) {
7617
7760
  const result = await $`git clone ${url} ${repoPath}`.quiet();
7618
7761
  if (result.exitCode !== 0) {
7619
7762
  const stderr = result.stderr.toString().trim();
7620
- throw new Error(`git clone failed: ${stderr || `exit code ${result.exitCode}`}`);
7763
+ const stdout = result.stdout.toString().trim();
7764
+ const output = [stderr, stdout].filter(Boolean).join(`
7765
+ `) || `exit code ${result.exitCode}`;
7766
+ throw new Error(`git clone failed: ${output}`);
7621
7767
  }
7622
7768
  }
7623
7769
  async function fetchAndCheckout(repoPath, ref) {
@@ -7626,20 +7772,29 @@ async function fetchAndCheckout(repoPath, ref) {
7626
7772
  const fetchResult = await $`git -C ${repoPath} fetch --all --prune`.quiet();
7627
7773
  if (fetchResult.exitCode !== 0) {
7628
7774
  const stderr = fetchResult.stderr.toString().trim();
7629
- throw new Error(`git fetch failed: ${stderr || `exit code ${fetchResult.exitCode}`}`);
7775
+ const stdout = fetchResult.stdout.toString().trim();
7776
+ const output = [stderr, stdout].filter(Boolean).join(`
7777
+ `) || `exit code ${fetchResult.exitCode}`;
7778
+ throw new Error(`git fetch failed: ${output}`);
7630
7779
  }
7631
7780
  if (ref) {
7632
7781
  const checkoutResult = await $`git -C ${repoPath} checkout ${ref}`.quiet();
7633
7782
  if (checkoutResult.exitCode !== 0) {
7634
7783
  const stderr = checkoutResult.stderr.toString().trim();
7635
- throw new Error(`git checkout ${ref} failed: ${stderr || `exit code ${checkoutResult.exitCode}`}`);
7784
+ const stdout = checkoutResult.stdout.toString().trim();
7785
+ const output = [stderr, stdout].filter(Boolean).join(`
7786
+ `) || `exit code ${checkoutResult.exitCode}`;
7787
+ throw new Error(`git checkout ${ref} failed: ${output}`);
7636
7788
  }
7637
7789
  const isBranch = await $`git -C ${repoPath} symbolic-ref -q HEAD`.quiet();
7638
7790
  if (isBranch.exitCode === 0) {
7639
7791
  const pullResult = await $`git -C ${repoPath} pull --ff-only`.quiet();
7640
7792
  if (pullResult.exitCode !== 0) {
7641
7793
  const stderr = pullResult.stderr.toString().trim();
7642
- throw new Error(`git pull failed: ${stderr || `exit code ${pullResult.exitCode}`}`);
7794
+ const stdout = pullResult.stdout.toString().trim();
7795
+ const output = [stderr, stdout].filter(Boolean).join(`
7796
+ `) || `exit code ${pullResult.exitCode}`;
7797
+ throw new Error(`git pull failed: ${output}`);
7643
7798
  }
7644
7799
  }
7645
7800
  } else {
@@ -7723,14 +7878,14 @@ async function discoverAgents(repoPath) {
7723
7878
  const findAgents = (dir, depth) => {
7724
7879
  if (depth > DISCOVERY_LIMITS.maxDepth) {
7725
7880
  if (!limitsWarned) {
7726
- console.warn(`[remote-skills] Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
7881
+ logWarn(`Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
7727
7882
  limitsWarned = true;
7728
7883
  }
7729
7884
  return;
7730
7885
  }
7731
7886
  if (filesProcessed >= DISCOVERY_LIMITS.maxFiles) {
7732
7887
  if (!limitsWarned) {
7733
- console.warn(`[remote-skills] Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
7888
+ logWarn(`Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
7734
7889
  limitsWarned = true;
7735
7890
  }
7736
7891
  return;
@@ -7748,7 +7903,7 @@ async function discoverAgents(repoPath) {
7748
7903
  try {
7749
7904
  const stats = fs.statSync(fullPath);
7750
7905
  if (stats.size > DISCOVERY_LIMITS.maxFileSize) {
7751
- console.warn(`[remote-skills] Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
7906
+ logWarn(`Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
7752
7907
  continue;
7753
7908
  }
7754
7909
  filesProcessed++;
@@ -7758,7 +7913,7 @@ async function discoverAgents(repoPath) {
7758
7913
  agents.push(parsed);
7759
7914
  }
7760
7915
  } catch (err) {
7761
- console.error(`[remote-skills] Failed to parse agent ${fullPath}:`, err);
7916
+ logError(`Failed to parse agent ${fullPath}: ${err}`);
7762
7917
  }
7763
7918
  }
7764
7919
  }
@@ -7784,7 +7939,7 @@ function parseAgentMarkdown(filePath, content, agentDir) {
7784
7939
  });
7785
7940
  } catch (err) {
7786
7941
  const relativeToRepo = path.relative(path.dirname(agentDir), filePath);
7787
- console.error(`[remote-skills] Failed to parse frontmatter in ${relativeToRepo}:`, err);
7942
+ logError(`Failed to parse frontmatter in ${relativeToRepo}: ${err}`);
7788
7943
  return null;
7789
7944
  }
7790
7945
  if (!md.data || Object.keys(md.data).length === 0) {
@@ -7794,7 +7949,7 @@ function parseAgentMarkdown(filePath, content, agentDir) {
7794
7949
  const agentName = relativePath.replace(/\.md$/i, "");
7795
7950
  if (!/^[a-zA-Z0-9_/-]+$/.test(agentName)) {
7796
7951
  const relativeToRepo = path.relative(path.dirname(agentDir), filePath);
7797
- console.warn(`[remote-skills] Skipping agent with invalid name characters: ${relativeToRepo}`);
7952
+ logWarn(`Skipping agent with invalid name characters: ${relativeToRepo}`);
7798
7953
  return null;
7799
7954
  }
7800
7955
  const rawConfig = {
@@ -7803,7 +7958,7 @@ function parseAgentMarkdown(filePath, content, agentDir) {
7803
7958
  };
7804
7959
  const result = AgentConfigSchema.safeParse(rawConfig);
7805
7960
  if (!result.success) {
7806
- console.error(`[remote-skills] Invalid agent config in ${filePath}:`, result.error.format());
7961
+ logError(`Invalid agent config in ${filePath}: ${JSON.stringify(result.error.format())}`);
7807
7962
  return null;
7808
7963
  }
7809
7964
  return {
@@ -7826,14 +7981,14 @@ async function discoverCommands(repoPath) {
7826
7981
  const findCommands = (dir, depth) => {
7827
7982
  if (depth > DISCOVERY_LIMITS.maxDepth) {
7828
7983
  if (!limitsWarned) {
7829
- console.warn(`[remote-skills] Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
7984
+ logWarn(`Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
7830
7985
  limitsWarned = true;
7831
7986
  }
7832
7987
  return;
7833
7988
  }
7834
7989
  if (filesProcessed >= DISCOVERY_LIMITS.maxFiles) {
7835
7990
  if (!limitsWarned) {
7836
- console.warn(`[remote-skills] Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
7991
+ logWarn(`Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
7837
7992
  limitsWarned = true;
7838
7993
  }
7839
7994
  return;
@@ -7851,7 +8006,7 @@ async function discoverCommands(repoPath) {
7851
8006
  try {
7852
8007
  const stats = fs.statSync(fullPath);
7853
8008
  if (stats.size > DISCOVERY_LIMITS.maxFileSize) {
7854
- console.warn(`[remote-skills] Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
8009
+ logWarn(`Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
7855
8010
  continue;
7856
8011
  }
7857
8012
  filesProcessed++;
@@ -7861,7 +8016,7 @@ async function discoverCommands(repoPath) {
7861
8016
  commands.push(parsed);
7862
8017
  }
7863
8018
  } catch (err) {
7864
- console.error(`[remote-skills] Failed to parse command ${fullPath}:`, err);
8019
+ logError(`Failed to parse command ${fullPath}: ${err}`);
7865
8020
  }
7866
8021
  }
7867
8022
  }
@@ -7887,14 +8042,14 @@ function parseCommandMarkdown(filePath, content, commandDir) {
7887
8042
  });
7888
8043
  } catch (err) {
7889
8044
  const relativeToRepo = path.relative(path.dirname(commandDir), filePath);
7890
- console.error(`[remote-skills] Failed to parse frontmatter in ${relativeToRepo}:`, err);
8045
+ logError(`Failed to parse frontmatter in ${relativeToRepo}: ${err}`);
7891
8046
  return null;
7892
8047
  }
7893
8048
  const relativePath = path.relative(commandDir, filePath).replace(/\\/g, "/");
7894
8049
  const commandName = relativePath.replace(/\.md$/i, "");
7895
8050
  if (!/^[a-zA-Z0-9_/-]+$/.test(commandName)) {
7896
8051
  const relativeToRepo = path.relative(path.dirname(commandDir), filePath);
7897
- console.warn(`[remote-skills] Skipping command with invalid name characters: ${relativeToRepo}`);
8052
+ logWarn(`Skipping command with invalid name characters: ${relativeToRepo}`);
7898
8053
  return null;
7899
8054
  }
7900
8055
  const rawConfig = {
@@ -7903,7 +8058,7 @@ function parseCommandMarkdown(filePath, content, commandDir) {
7903
8058
  };
7904
8059
  const result = CommandConfigSchema.safeParse(rawConfig);
7905
8060
  if (!result.success) {
7906
- console.error(`[remote-skills] Invalid command config in ${filePath}:`, result.error.format());
8061
+ logError(`Invalid command config in ${filePath}: ${JSON.stringify(result.error.format())}`);
7907
8062
  return null;
7908
8063
  }
7909
8064
  return {
@@ -7926,14 +8081,14 @@ async function discoverPlugins(repoPath, repoShortName) {
7926
8081
  const findPlugins = (dir, depth) => {
7927
8082
  if (depth > DISCOVERY_LIMITS.maxDepth) {
7928
8083
  if (!limitsWarned) {
7929
- console.warn(`[remote-skills] Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
8084
+ logWarn(`Skipping deep directories (max depth: ${DISCOVERY_LIMITS.maxDepth})`);
7930
8085
  limitsWarned = true;
7931
8086
  }
7932
8087
  return;
7933
8088
  }
7934
8089
  if (filesProcessed >= DISCOVERY_LIMITS.maxFiles) {
7935
8090
  if (!limitsWarned) {
7936
- console.warn(`[remote-skills] Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
8091
+ logWarn(`Stopping discovery (max files: ${DISCOVERY_LIMITS.maxFiles})`);
7937
8092
  limitsWarned = true;
7938
8093
  }
7939
8094
  return;
@@ -7951,7 +8106,7 @@ async function discoverPlugins(repoPath, repoShortName) {
7951
8106
  try {
7952
8107
  const stats = fs.statSync(fullPath);
7953
8108
  if (stats.size > DISCOVERY_LIMITS.maxFileSize) {
7954
- console.warn(`[remote-skills] Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
8109
+ logWarn(`Skipping large file (${Math.round(stats.size / 1024)}KB): ${entry.name}`);
7955
8110
  continue;
7956
8111
  }
7957
8112
  filesProcessed++;
@@ -7960,7 +8115,7 @@ async function discoverPlugins(repoPath, repoShortName) {
7960
8115
  const pluginName = relativePath.slice(0, -ext.length).replace(/\//g, "-");
7961
8116
  if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
7962
8117
  const relativeToRepo = path.relative(path.dirname(pluginDir), fullPath);
7963
- console.warn(`[remote-skills] Skipping plugin with invalid name characters: ${relativeToRepo}`);
8118
+ logWarn(`Skipping plugin with invalid name characters: ${relativeToRepo}`);
7964
8119
  continue;
7965
8120
  }
7966
8121
  plugins.push({
@@ -7970,7 +8125,7 @@ async function discoverPlugins(repoPath, repoShortName) {
7970
8125
  extension: ext
7971
8126
  });
7972
8127
  } catch (err) {
7973
- console.error(`[remote-skills] Failed to process plugin ${fullPath}:`, err);
8128
+ logError(`Failed to process plugin ${fullPath}: ${err}`);
7974
8129
  }
7975
8130
  }
7976
8131
  }
@@ -7993,31 +8148,22 @@ async function syncLocalDirectory(config) {
7993
8148
  let agents = [];
7994
8149
  let commands = [];
7995
8150
  let plugins = [];
8151
+ let instructions = [];
7996
8152
  if (!fs.existsSync(localPath)) {
7997
8153
  error = `Local directory not found: ${localPath}`;
7998
8154
  } else if (!fs.statSync(localPath).isDirectory()) {
7999
8155
  error = `Not a directory: ${localPath}`;
8000
8156
  } else {
8001
8157
  skills = await discoverSkills(localPath);
8002
- if (config.skills && config.skills !== "*" && Array.isArray(config.skills)) {
8003
- const requestedSkills = new Set(config.skills);
8004
- skills = skills.filter((s) => requestedSkills.has(s.name));
8005
- }
8158
+ skills = skills.filter((s) => shouldImport(s.name, config.skills));
8006
8159
  agents = await discoverAgents(localPath);
8007
- if (config.agents && config.agents !== "*" && Array.isArray(config.agents)) {
8008
- const requestedAgents = new Set(config.agents);
8009
- agents = agents.filter((a) => requestedAgents.has(a.name));
8010
- }
8160
+ agents = agents.filter((a) => shouldImport(a.name, config.agents));
8011
8161
  commands = await discoverCommands(localPath);
8012
- if (config.commands && config.commands !== "*" && Array.isArray(config.commands)) {
8013
- const requestedCommands = new Set(config.commands);
8014
- commands = commands.filter((c) => requestedCommands.has(c.name));
8015
- }
8162
+ commands = commands.filter((c) => shouldImport(c.name, config.commands));
8016
8163
  plugins = await discoverPlugins(localPath, shortName);
8017
- if (config.plugins && config.plugins !== "*" && Array.isArray(config.plugins)) {
8018
- const requestedPlugins = new Set(config.plugins);
8019
- plugins = plugins.filter((p) => requestedPlugins.has(p.name));
8020
- }
8164
+ plugins = plugins.filter((p) => shouldImport(p.name, config.plugins));
8165
+ instructions = discoverInstructions(localPath);
8166
+ instructions = instructions.filter((i) => shouldImport(i.name, config.instructions));
8021
8167
  }
8022
8168
  return {
8023
8169
  repoId,
@@ -8028,6 +8174,7 @@ async function syncLocalDirectory(config) {
8028
8174
  agents,
8029
8175
  commands,
8030
8176
  plugins,
8177
+ instructions,
8031
8178
  updated: false,
8032
8179
  error
8033
8180
  };
@@ -8055,29 +8202,20 @@ async function syncGitRepository(config) {
8055
8202
  let agents = [];
8056
8203
  let commands = [];
8057
8204
  let plugins = [];
8205
+ let instructions = [];
8058
8206
  let currentRef = config.ref || "default";
8059
8207
  if (isCloned(repoPath)) {
8060
8208
  skills = await discoverSkills(repoPath);
8061
8209
  currentRef = await getCurrentRef(repoPath);
8062
- if (config.skills && config.skills !== "*" && Array.isArray(config.skills)) {
8063
- const requestedSkills = new Set(config.skills);
8064
- skills = skills.filter((s) => requestedSkills.has(s.name));
8065
- }
8210
+ skills = skills.filter((s) => shouldImport(s.name, config.skills));
8066
8211
  agents = await discoverAgents(repoPath);
8067
- if (config.agents && config.agents !== "*" && Array.isArray(config.agents)) {
8068
- const requestedAgents = new Set(config.agents);
8069
- agents = agents.filter((a) => requestedAgents.has(a.name));
8070
- }
8212
+ agents = agents.filter((a) => shouldImport(a.name, config.agents));
8071
8213
  commands = await discoverCommands(repoPath);
8072
- if (config.commands && config.commands !== "*" && Array.isArray(config.commands)) {
8073
- const requestedCommands = new Set(config.commands);
8074
- commands = commands.filter((c) => requestedCommands.has(c.name));
8075
- }
8214
+ commands = commands.filter((c) => shouldImport(c.name, config.commands));
8076
8215
  plugins = await discoverPlugins(repoPath, shortName);
8077
- if (config.plugins && config.plugins !== "*" && Array.isArray(config.plugins)) {
8078
- const requestedPlugins = new Set(config.plugins);
8079
- plugins = plugins.filter((p) => requestedPlugins.has(p.name));
8080
- }
8216
+ plugins = plugins.filter((p) => shouldImport(p.name, config.plugins));
8217
+ instructions = discoverInstructions(repoPath);
8218
+ instructions = instructions.filter((i) => shouldImport(i.name, config.instructions));
8081
8219
  }
8082
8220
  return {
8083
8221
  repoId,
@@ -8088,6 +8226,7 @@ async function syncGitRepository(config) {
8088
8226
  agents,
8089
8227
  commands,
8090
8228
  plugins,
8229
+ instructions,
8091
8230
  updated,
8092
8231
  error
8093
8232
  };
@@ -8101,20 +8240,122 @@ async function syncRepositories(configs) {
8101
8240
  return results;
8102
8241
  }
8103
8242
 
8104
- // src/symlinks.ts
8105
- import * as path2 from "path";
8106
- 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
8107
8249
  var {$: $2 } = globalThis.Bun;
8108
- var SKILL_BASE = path2.join(process.env.HOME || "~", ".config", "opencode", "skill");
8109
- var PLUGINS_DIR = path2.join(SKILL_BASE, "_plugins");
8110
- function getSymlinkPath(repoShortName, skillName) {
8111
- 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);
8112
8337
  }
8113
8338
  function ensurePluginsDir() {
8114
- 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 };
8115
8356
  }
8116
- function createSkillSymlink(skill, repoShortName) {
8117
- const targetPath = getSymlinkPath(repoShortName, skill.name);
8357
+ async function createSkillInstall(skill, repoShortName, installMethod = "link") {
8358
+ const targetPath = getInstallPath(repoShortName, skill.name);
8118
8359
  const result = {
8119
8360
  skillName: skill.name,
8120
8361
  sourcePath: skill.path,
@@ -8122,78 +8363,81 @@ function createSkillSymlink(skill, repoShortName) {
8122
8363
  created: false
8123
8364
  };
8124
8365
  try {
8125
- fs2.mkdirSync(path2.dirname(targetPath), { recursive: true });
8126
- if (fs2.existsSync(targetPath)) {
8127
- const stats = fs2.lstatSync(targetPath);
8128
- if (stats.isSymbolicLink()) {
8129
- const existingTarget = fs2.readlinkSync(targetPath);
8130
- if (existingTarget === skill.path) {
8131
- return result;
8132
- }
8133
- fs2.unlinkSync(targetPath);
8134
- } else {
8135
- result.error = `Path exists and is not a symlink: ${targetPath}`;
8136
- 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;
8137
8375
  }
8138
8376
  }
8139
- fs2.symlinkSync(skill.path, targetPath, "dir");
8140
- result.created = true;
8141
8377
  } catch (err) {
8142
8378
  result.error = err instanceof Error ? err.message : String(err);
8143
8379
  }
8144
8380
  return result;
8145
8381
  }
8146
- function createSymlinksForRepo(syncResult) {
8382
+ async function createInstallsForRepo(syncResult, installMethod = "link") {
8147
8383
  ensurePluginsDir();
8148
8384
  const results = [];
8149
8385
  for (const skill of syncResult.skills) {
8150
- const result = createSkillSymlink(skill, syncResult.shortName);
8386
+ const result = await createSkillInstall(skill, syncResult.shortName, installMethod);
8151
8387
  results.push(result);
8152
8388
  }
8153
8389
  return results;
8154
8390
  }
8155
- function getExistingSymlinks() {
8156
- const symlinks = new Map;
8157
- if (!fs2.existsSync(PLUGINS_DIR)) {
8158
- return symlinks;
8159
- }
8160
- const scanDir = (dir, prefix = "") => {
8161
- const entries = fs2.readdirSync(dir, { withFileTypes: true });
8162
- for (const entry of entries) {
8163
- 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("."))
8164
8404
  continue;
8165
- const fullPath = path2.join(dir, entry.name);
8166
- const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
8167
- if (entry.isSymbolicLink()) {
8168
- const target = fs2.readlinkSync(fullPath);
8169
- symlinks.set(relativePath, target);
8170
- } else if (entry.isDirectory()) {
8171
- 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);
8172
8412
  }
8173
8413
  }
8174
- };
8175
- scanDir(PLUGINS_DIR);
8176
- return symlinks;
8414
+ }
8415
+ return installs;
8177
8416
  }
8178
- function cleanupStaleSymlinks(currentSkills) {
8417
+ function cleanupStaleInstalls(currentSkills) {
8179
8418
  const result = {
8180
8419
  removed: [],
8181
8420
  errors: []
8182
8421
  };
8183
- const existingSymlinks = getExistingSymlinks();
8184
- for (const [relativePath] of existingSymlinks) {
8422
+ const existingInstalls = getExistingInstalls();
8423
+ for (const [relativePath] of existingInstalls) {
8185
8424
  if (!currentSkills.has(relativePath)) {
8186
- const fullPath = path2.join(PLUGINS_DIR, relativePath);
8425
+ const fullPath = path3.join(PLUGINS_DIR, relativePath);
8187
8426
  try {
8188
- 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
+ }
8189
8433
  result.removed.push(relativePath);
8190
- let parentDir = path2.dirname(fullPath);
8434
+ let parentDir = path3.dirname(fullPath);
8191
8435
  while (parentDir !== PLUGINS_DIR && parentDir.startsWith(PLUGINS_DIR)) {
8192
8436
  try {
8193
- const entries = fs2.readdirSync(parentDir);
8437
+ const entries = fs3.readdirSync(parentDir);
8194
8438
  if (entries.length === 0) {
8195
- fs2.rmdirSync(parentDir);
8196
- parentDir = path2.dirname(parentDir);
8439
+ fs3.rmdirSync(parentDir);
8440
+ parentDir = path3.dirname(parentDir);
8197
8441
  } else {
8198
8442
  break;
8199
8443
  }
@@ -8209,16 +8453,16 @@ function cleanupStaleSymlinks(currentSkills) {
8209
8453
  return result;
8210
8454
  }
8211
8455
  function hasLocalConflict(skillName) {
8212
- const localSkillPath = path2.join(SKILL_BASE, skillName);
8213
- if (fs2.existsSync(localSkillPath)) {
8214
- const realPath = fs2.realpathSync(localSkillPath);
8456
+ const localSkillPath = path3.join(SKILL_BASE, skillName);
8457
+ if (fs3.existsSync(localSkillPath)) {
8458
+ const realPath = fs3.realpathSync(localSkillPath);
8215
8459
  return !realPath.includes("_plugins");
8216
8460
  }
8217
8461
  return false;
8218
8462
  }
8219
8463
  async function findGitRoot(startPath) {
8220
8464
  try {
8221
- 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();
8222
8466
  if (result.exitCode === 0) {
8223
8467
  return result.stdout.toString().trim();
8224
8468
  }
@@ -8230,14 +8474,14 @@ async function ensureGitignore() {
8230
8474
  if (!gitRoot) {
8231
8475
  return { modified: false, gitRoot: null };
8232
8476
  }
8233
- const gitignorePath = path2.join(gitRoot, ".gitignore");
8477
+ const gitignorePath = path3.join(gitRoot, ".gitignore");
8234
8478
  const pluginsEntry = "_plugins/";
8235
- const relativePath = path2.relative(gitRoot, PLUGINS_DIR);
8479
+ const relativePath = path3.relative(gitRoot, PLUGINS_DIR);
8236
8480
  const gitignoreEntry = relativePath ? `${relativePath}/` : pluginsEntry;
8237
8481
  let content = "";
8238
8482
  let exists = false;
8239
8483
  try {
8240
- content = fs2.readFileSync(gitignorePath, "utf-8");
8484
+ content = fs3.readFileSync(gitignorePath, "utf-8");
8241
8485
  exists = true;
8242
8486
  } catch {}
8243
8487
  const lines = content.split(`
@@ -8259,27 +8503,41 @@ ${gitignoreEntry}
8259
8503
  ` : `# OpenCode remote skills plugin
8260
8504
  ${gitignoreEntry}
8261
8505
  `;
8262
- fs2.writeFileSync(gitignorePath, newContent);
8506
+ fs3.writeFileSync(gitignorePath, newContent);
8263
8507
  return { modified: true, gitRoot };
8264
8508
  }
8265
8509
 
8266
- // src/plugin-symlinks.ts
8267
- import * as fs3 from "fs";
8268
- import * as path3 from "path";
8269
- import { homedir as homedir2 } from "os";
8270
- 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");
8271
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
+ }
8272
8530
  function getPluginSymlinkName(plugin) {
8273
8531
  return `${REMOTE_PREFIX}${plugin.repoShortName}_${plugin.name}${plugin.extension}`;
8274
8532
  }
8275
8533
  function ensurePluginDir(pluginDir = DEFAULT_PLUGIN_DIR) {
8276
- if (!fs3.existsSync(pluginDir)) {
8277
- fs3.mkdirSync(pluginDir, { recursive: true });
8534
+ if (!fs4.existsSync(pluginDir)) {
8535
+ fs4.mkdirSync(pluginDir, { recursive: true });
8278
8536
  }
8279
8537
  }
8280
- function createPluginSymlink(plugin, pluginDir = DEFAULT_PLUGIN_DIR) {
8538
+ function createPluginInstall(plugin, pluginDir = DEFAULT_PLUGIN_DIR, installMethod = "link") {
8281
8539
  const symlinkName = getPluginSymlinkName(plugin);
8282
- const symlinkPath = path3.join(pluginDir, symlinkName);
8540
+ const symlinkPath = path4.join(pluginDir, symlinkName);
8283
8541
  const result = {
8284
8542
  pluginName: plugin.name,
8285
8543
  symlinkName,
@@ -8288,40 +8546,42 @@ function createPluginSymlink(plugin, pluginDir = DEFAULT_PLUGIN_DIR) {
8288
8546
  };
8289
8547
  try {
8290
8548
  ensurePluginDir(pluginDir);
8291
- if (fs3.existsSync(symlinkPath)) {
8292
- fs3.unlinkSync(symlinkPath);
8549
+ removePathIfExists(symlinkPath);
8550
+ if (installMethod === "copy") {
8551
+ fs4.cpSync(plugin.path, symlinkPath);
8552
+ } else {
8553
+ fs4.symlinkSync(plugin.path, symlinkPath);
8293
8554
  }
8294
- fs3.symlinkSync(plugin.path, symlinkPath);
8295
8555
  } catch (err) {
8296
8556
  result.error = err instanceof Error ? err.message : String(err);
8297
8557
  }
8298
8558
  return result;
8299
8559
  }
8300
- function createPluginSymlinks(plugins, pluginDir = DEFAULT_PLUGIN_DIR) {
8301
- 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));
8302
8562
  }
8303
- function getRemotePluginSymlinks(pluginDir = DEFAULT_PLUGIN_DIR) {
8304
- if (!fs3.existsSync(pluginDir)) {
8563
+ function getRemotePluginInstalls(pluginDir = DEFAULT_PLUGIN_DIR) {
8564
+ if (!fs4.existsSync(pluginDir)) {
8305
8565
  return [];
8306
8566
  }
8307
8567
  try {
8308
- const entries = fs3.readdirSync(pluginDir);
8568
+ const entries = fs4.readdirSync(pluginDir);
8309
8569
  return entries.filter((name) => name.startsWith(REMOTE_PREFIX));
8310
8570
  } catch {
8311
8571
  return [];
8312
8572
  }
8313
8573
  }
8314
- function cleanupStalePluginSymlinks(currentSymlinks, pluginDir = DEFAULT_PLUGIN_DIR) {
8574
+ function cleanupStalePluginInstalls(currentInstalls, pluginDir = DEFAULT_PLUGIN_DIR) {
8315
8575
  const result = {
8316
8576
  removed: [],
8317
8577
  errors: []
8318
8578
  };
8319
- const existing = getRemotePluginSymlinks(pluginDir);
8579
+ const existing = getRemotePluginInstalls(pluginDir);
8320
8580
  for (const name of existing) {
8321
- if (!currentSymlinks.has(name)) {
8322
- const symlinkPath = path3.join(pluginDir, name);
8581
+ if (!currentInstalls.has(name)) {
8582
+ const installPath = path4.join(pluginDir, name);
8323
8583
  try {
8324
- fs3.unlinkSync(symlinkPath);
8584
+ removePathIfExists(installPath);
8325
8585
  result.removed.push(name);
8326
8586
  } catch (err) {
8327
8587
  result.errors.push(`Failed to remove ${name}: ${err instanceof Error ? err.message : String(err)}`);
@@ -8332,36 +8592,10 @@ function cleanupStalePluginSymlinks(currentSymlinks, pluginDir = DEFAULT_PLUGIN_
8332
8592
  }
8333
8593
 
8334
8594
  // src/index.ts
8335
- import { appendFileSync, mkdirSync as mkdirSync4 } from "fs";
8336
- import { join as join5 } from "path";
8337
- import { homedir as homedir3 } from "os";
8338
- var LOG_PREFIX = "[remote-skills]";
8339
- var LOG_DIR = join5(homedir3(), ".cache", "opencode", "remote-skills");
8340
- var LOG_FILE = join5(LOG_DIR, "plugin.log");
8341
8595
  var initialized = false;
8342
- function timestamp() {
8343
- return new Date().toISOString();
8344
- }
8345
- function writeLog(level, message) {
8346
- try {
8347
- mkdirSync4(LOG_DIR, { recursive: true });
8348
- appendFileSync(LOG_FILE, `${timestamp()} [${level}] ${message}
8349
- `);
8350
- } catch {}
8351
- }
8352
- function log(message) {
8353
- const fullMessage = `${LOG_PREFIX} ${message}`;
8354
- console.log(fullMessage);
8355
- writeLog("INFO", message);
8356
- }
8357
- function logError(message) {
8358
- const fullMessage = `${LOG_PREFIX} ${message}`;
8359
- console.error(fullMessage);
8360
- writeLog("ERROR", message);
8361
- }
8362
8596
  async function performSync(config) {
8363
8597
  if (config.repositories.length === 0) {
8364
- return { results: [], skippedConflicts: [], totalSkills: 0, remoteAgents: new Map, remoteCommands: new Map, pluginsChanged: false, totalPlugins: 0 };
8598
+ return { results: [], skippedConflicts: [], totalSkills: 0, remoteAgents: new Map, remoteCommands: new Map, remoteInstructions: [], pluginsChanged: false, totalPlugins: 0 };
8365
8599
  }
8366
8600
  log(`Syncing ${config.repositories.length} repositories...`);
8367
8601
  const results = await syncRepositories(config.repositories);
@@ -8373,30 +8607,30 @@ async function performSync(config) {
8373
8607
  logError(`\u2717 Failed to sync ${result.shortName}: ${result.error}`);
8374
8608
  continue;
8375
8609
  }
8376
- const skillsToLink = result.skills.filter((skill) => {
8610
+ const skillsToInstall = result.skills.filter((skill) => {
8377
8611
  if (hasLocalConflict(skill.name)) {
8378
8612
  skippedConflicts.push(skill.name);
8379
8613
  return false;
8380
8614
  }
8381
8615
  return true;
8382
8616
  });
8383
- const filteredResult = { ...result, skills: skillsToLink };
8384
- const symlinkResults = createSymlinksForRepo(filteredResult);
8385
- 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) {
8386
8620
  if (!sr.error) {
8387
8621
  currentSkills.add(`${result.shortName}/${sr.skillName}`);
8388
8622
  totalSkills++;
8389
8623
  } else {
8390
- logError(`\u2717 Failed to create symlink for ${sr.skillName}: ${sr.error}`);
8624
+ logError(`\u2717 Failed to install skill ${sr.skillName}: ${sr.error}`);
8391
8625
  }
8392
8626
  }
8393
- const skillCount = skillsToLink.length;
8627
+ const skillCount = skillsToInstall.length;
8394
8628
  const status = result.updated ? "\u2713" : "\u2713";
8395
8629
  log(`${status} ${result.shortName} (${result.ref}) - ${skillCount} skills`);
8396
8630
  }
8397
- const cleanup = cleanupStaleSymlinks(currentSkills);
8631
+ const cleanup = cleanupStaleInstalls(currentSkills);
8398
8632
  if (cleanup.removed.length > 0) {
8399
- log(`Cleaned up ${cleanup.removed.length} stale symlinks`);
8633
+ log(`Cleaned up ${cleanup.removed.length} stale skill installs`);
8400
8634
  }
8401
8635
  for (const conflict of skippedConflicts) {
8402
8636
  log(`\u26A0 Conflict: '${conflict}' exists locally, skipping`);
@@ -8439,32 +8673,43 @@ async function performSync(config) {
8439
8673
  if (remoteCommands.size > 0) {
8440
8674
  log(`Discovered ${remoteCommands.size} remote commands`);
8441
8675
  }
8676
+ const remoteInstructions = [];
8677
+ for (const result of results) {
8678
+ if (result.error)
8679
+ continue;
8680
+ for (const instruction of result.instructions) {
8681
+ remoteInstructions.push(instruction.path);
8682
+ }
8683
+ }
8684
+ if (remoteInstructions.length > 0) {
8685
+ log(`Discovered ${remoteInstructions.length} remote instructions`);
8686
+ }
8442
8687
  const allPlugins = [];
8443
8688
  for (const result of results) {
8444
8689
  if (result.error)
8445
8690
  continue;
8446
8691
  allPlugins.push(...result.plugins);
8447
8692
  }
8448
- const existingPluginSymlinks = new Set(getRemotePluginSymlinks());
8449
- const newPluginSymlinks = new Set;
8693
+ const existingPluginInstalls = new Set(getRemotePluginInstalls());
8694
+ const newPluginInstalls = new Set;
8450
8695
  if (allPlugins.length > 0) {
8451
- const symlinkResults = createPluginSymlinks(allPlugins);
8452
- for (const sr of symlinkResults) {
8696
+ const installResults = createPluginInstalls(allPlugins, undefined, config.installMethod);
8697
+ for (const sr of installResults) {
8453
8698
  if (!sr.error) {
8454
- newPluginSymlinks.add(sr.symlinkName);
8699
+ newPluginInstalls.add(sr.symlinkName);
8455
8700
  } else {
8456
- logError(`\u2717 Failed to create plugin symlink for ${sr.pluginName}: ${sr.error}`);
8701
+ logError(`\u2717 Failed to install plugin ${sr.pluginName}: ${sr.error}`);
8457
8702
  }
8458
8703
  }
8459
8704
  log(`Discovered ${allPlugins.length} remote plugins`);
8460
8705
  }
8461
- const pluginCleanup = cleanupStalePluginSymlinks(newPluginSymlinks);
8706
+ const pluginCleanup = cleanupStalePluginInstalls(newPluginInstalls);
8462
8707
  if (pluginCleanup.removed.length > 0) {
8463
- log(`Cleaned up ${pluginCleanup.removed.length} stale plugin symlinks`);
8708
+ log(`Cleaned up ${pluginCleanup.removed.length} stale plugin installs`);
8464
8709
  }
8465
- const pluginsChanged = !setsEqual(existingPluginSymlinks, newPluginSymlinks);
8466
- const totalPlugins = newPluginSymlinks.size;
8467
- return { results, skippedConflicts, totalSkills, remoteAgents, remoteCommands, pluginsChanged, totalPlugins };
8710
+ const pluginsChanged = !setsEqual(existingPluginInstalls, newPluginInstalls);
8711
+ const totalPlugins = newPluginInstalls.size;
8712
+ return { results, skippedConflicts, totalSkills, remoteAgents, remoteCommands, remoteInstructions, pluginsChanged, totalPlugins };
8468
8713
  }
8469
8714
  function setsEqual(a, b) {
8470
8715
  if (a.size !== b.size)
@@ -8484,12 +8729,17 @@ var RemoteSkillsPlugin = async (ctx) => {
8484
8729
  if (pluginConfig.repositories.length === 0) {
8485
8730
  return {};
8486
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
+ }
8487
8737
  ensurePluginsDir();
8488
8738
  const gitignoreResult = await ensureGitignore();
8489
8739
  if (gitignoreResult.modified) {
8490
8740
  log(`Added _plugins/ to .gitignore at ${gitignoreResult.gitRoot}`);
8491
8741
  }
8492
- const { totalSkills, skippedConflicts, remoteAgents, remoteCommands, pluginsChanged, totalPlugins } = await performSync(pluginConfig);
8742
+ const { totalSkills, skippedConflicts, remoteAgents, remoteCommands, remoteInstructions, pluginsChanged, totalPlugins } = await performSync(pluginConfig);
8493
8743
  if (pluginsChanged) {
8494
8744
  log("\u26A0 Plugin changes detected. Restart OpenCode to apply.");
8495
8745
  }
@@ -8499,9 +8749,11 @@ var RemoteSkillsPlugin = async (ctx) => {
8499
8749
  parts.push(`${totalSkills} skills`);
8500
8750
  if (totalPlugins > 0)
8501
8751
  parts.push(`${totalPlugins} plugins`);
8752
+ if (remoteInstructions.length > 0)
8753
+ parts.push(`${remoteInstructions.length} instructions`);
8502
8754
  let message;
8503
8755
  if (parts.length === 0) {
8504
- message = "No remote skills or plugins found";
8756
+ message = "No remote config found";
8505
8757
  } else {
8506
8758
  message = `${parts.join(", ")} available`;
8507
8759
  if (skippedCount > 0) {
@@ -8547,6 +8799,19 @@ var RemoteSkillsPlugin = async (ctx) => {
8547
8799
  }
8548
8800
  }
8549
8801
  }
8802
+ if (remoteInstructions.length > 0) {
8803
+ if (config.instructions !== undefined && typeof config.instructions !== "string" && !Array.isArray(config.instructions)) {
8804
+ logError("config.instructions is not a string or array, skipping instruction injection");
8805
+ } else if (Array.isArray(config.instructions) && !config.instructions.every((x) => typeof x === "string")) {
8806
+ logError("config.instructions contains non-string elements, skipping instruction injection");
8807
+ } else {
8808
+ if (!Array.isArray(config.instructions)) {
8809
+ config.instructions = config.instructions ? [config.instructions] : [];
8810
+ }
8811
+ config.instructions.push(...remoteInstructions);
8812
+ log(`Appended ${remoteInstructions.length} remote instructions`);
8813
+ }
8814
+ }
8550
8815
  },
8551
8816
  event: async ({ event }) => {}
8552
8817
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgordijn/opencode-remote-config",
3
- "version": "0.1.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",