@algochad/archcoder 2.0.2

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 (157) hide show
  1. package/README.md +113 -0
  2. package/bin/cli-entry.js +55 -0
  3. package/bin/cli-output.js +145 -0
  4. package/bin/cli.js +5108 -0
  5. package/bin/cli.test.js +56 -0
  6. package/dist/apple-touch-icon-120x120.png +0 -0
  7. package/dist/apple-touch-icon-152x152.png +0 -0
  8. package/dist/apple-touch-icon-167x167.png +0 -0
  9. package/dist/apple-touch-icon-180x180.png +0 -0
  10. package/dist/apple-touch-icon.png +0 -0
  11. package/dist/apple-touch-icon.svg +67 -0
  12. package/dist/assets/MultiRunWindow-BZp3MjJP.js +1 -0
  13. package/dist/assets/SettingsWindow-DoGYXpX7.js +1 -0
  14. package/dist/assets/TerminalView-BN7BR5Ff.js +3 -0
  15. package/dist/assets/TimelineDialog-ZQ33oVQR.js +1 -0
  16. package/dist/assets/ToolOutputDialog-Blv3pnug.js +16 -0
  17. package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  18. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  19. package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  20. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  21. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  22. package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  23. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  24. package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  25. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  26. package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  27. package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  28. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  29. package/dist/assets/index-CtCEGYrr.css +1 -0
  30. package/dist/assets/index-o_d2wtWC.js +48 -0
  31. package/dist/assets/main-5QGBtzdq.css +1 -0
  32. package/dist/assets/main-B6oiMU86.js +8033 -0
  33. package/dist/assets/vendor--DbVqbJpV.css +1 -0
  34. package/dist/assets/vendor-.bun-HTKwyaEM.js +10086 -0
  35. package/dist/assets/wasm-CG6Dc4jp.js +1 -0
  36. package/dist/assets/worker-bqd4RMrj.js +155 -0
  37. package/dist/favicon-16.png +0 -0
  38. package/dist/favicon-32.png +0 -0
  39. package/dist/favicon.png +0 -0
  40. package/dist/favicon.svg +67 -0
  41. package/dist/index.html +533 -0
  42. package/dist/logo-dark-192x192.png +0 -0
  43. package/dist/logo-dark-512x512.svg +16 -0
  44. package/dist/logo-light-192x192.png +0 -0
  45. package/dist/logo-light-512x512.svg +16 -0
  46. package/dist/pwa-192.png +0 -0
  47. package/dist/pwa-512.png +0 -0
  48. package/dist/pwa-maskable-192.png +0 -0
  49. package/dist/pwa-maskable-512.png +0 -0
  50. package/dist/site.webmanifest +22 -0
  51. package/dist/sw.js +1 -0
  52. package/package.json +107 -0
  53. package/public/apple-touch-icon-120x120.png +0 -0
  54. package/public/apple-touch-icon-152x152.png +0 -0
  55. package/public/apple-touch-icon-167x167.png +0 -0
  56. package/public/apple-touch-icon-180x180.png +0 -0
  57. package/public/apple-touch-icon.png +0 -0
  58. package/public/apple-touch-icon.svg +67 -0
  59. package/public/favicon-16.png +0 -0
  60. package/public/favicon-32.png +0 -0
  61. package/public/favicon.png +0 -0
  62. package/public/favicon.svg +67 -0
  63. package/public/logo-dark-192x192.png +0 -0
  64. package/public/logo-dark-512x512.svg +16 -0
  65. package/public/logo-light-192x192.png +0 -0
  66. package/public/logo-light-512x512.svg +16 -0
  67. package/public/pwa-192.png +0 -0
  68. package/public/pwa-512.png +0 -0
  69. package/public/pwa-maskable-192.png +0 -0
  70. package/public/pwa-maskable-512.png +0 -0
  71. package/public/site.webmanifest +22 -0
  72. package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
  73. package/server/index.d.ts +37 -0
  74. package/server/index.js +14694 -0
  75. package/server/lib/cloudflare-tunnel.js +650 -0
  76. package/server/lib/git/DOCUMENTATION.md +146 -0
  77. package/server/lib/git/credentials.js +74 -0
  78. package/server/lib/git/identity-storage.js +110 -0
  79. package/server/lib/git/index.js +6 -0
  80. package/server/lib/git/service.js +3117 -0
  81. package/server/lib/github/DOCUMENTATION.md +170 -0
  82. package/server/lib/github/auth.js +307 -0
  83. package/server/lib/github/device-flow.js +50 -0
  84. package/server/lib/github/index.js +24 -0
  85. package/server/lib/github/octokit.js +10 -0
  86. package/server/lib/github/pr-status.js +478 -0
  87. package/server/lib/github/repo/index.js +55 -0
  88. package/server/lib/installer/desktop.js +289 -0
  89. package/server/lib/installer/download.js +208 -0
  90. package/server/lib/installer/index.js +45 -0
  91. package/server/lib/installer/platform.js +100 -0
  92. package/server/lib/notifications/DOCUMENTATION.md +61 -0
  93. package/server/lib/notifications/index.js +1 -0
  94. package/server/lib/notifications/message.js +49 -0
  95. package/server/lib/notifications/message.test.js +59 -0
  96. package/server/lib/opencode/DOCUMENTATION.md +59 -0
  97. package/server/lib/opencode/agents.js +634 -0
  98. package/server/lib/opencode/auth.js +81 -0
  99. package/server/lib/opencode/commands.js +339 -0
  100. package/server/lib/opencode/index.js +66 -0
  101. package/server/lib/opencode/mcp.js +206 -0
  102. package/server/lib/opencode/providers.js +96 -0
  103. package/server/lib/opencode/shared.js +527 -0
  104. package/server/lib/opencode/skills.js +480 -0
  105. package/server/lib/opencode/tunnel-auth.js +591 -0
  106. package/server/lib/opencode/ui-auth.js +510 -0
  107. package/server/lib/package-manager.js +505 -0
  108. package/server/lib/quota/DOCUMENTATION.md +55 -0
  109. package/server/lib/quota/index.js +24 -0
  110. package/server/lib/quota/providers/claude.js +107 -0
  111. package/server/lib/quota/providers/codex.js +113 -0
  112. package/server/lib/quota/providers/copilot.js +165 -0
  113. package/server/lib/quota/providers/google/api.js +92 -0
  114. package/server/lib/quota/providers/google/auth.js +108 -0
  115. package/server/lib/quota/providers/google/index.js +124 -0
  116. package/server/lib/quota/providers/google/transforms.js +109 -0
  117. package/server/lib/quota/providers/index.js +152 -0
  118. package/server/lib/quota/providers/interface.js +55 -0
  119. package/server/lib/quota/providers/kimi.js +108 -0
  120. package/server/lib/quota/providers/minimax-cn-coding-plan.js +15 -0
  121. package/server/lib/quota/providers/minimax-coding-plan.js +15 -0
  122. package/server/lib/quota/providers/minimax-shared.js +136 -0
  123. package/server/lib/quota/providers/nanogpt.js +124 -0
  124. package/server/lib/quota/providers/ollama-cloud.js +112 -0
  125. package/server/lib/quota/providers/openai.js +91 -0
  126. package/server/lib/quota/providers/openrouter.js +92 -0
  127. package/server/lib/quota/providers/zai.js +91 -0
  128. package/server/lib/quota/utils/auth.js +46 -0
  129. package/server/lib/quota/utils/formatters.js +76 -0
  130. package/server/lib/quota/utils/index.js +10 -0
  131. package/server/lib/quota/utils/transformers.js +55 -0
  132. package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
  133. package/server/lib/skills-catalog/cache.js +32 -0
  134. package/server/lib/skills-catalog/clawdhub/api.js +158 -0
  135. package/server/lib/skills-catalog/clawdhub/index.js +30 -0
  136. package/server/lib/skills-catalog/clawdhub/install.js +238 -0
  137. package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
  138. package/server/lib/skills-catalog/curated-sources.js +21 -0
  139. package/server/lib/skills-catalog/git.js +77 -0
  140. package/server/lib/skills-catalog/index.js +42 -0
  141. package/server/lib/skills-catalog/install.js +294 -0
  142. package/server/lib/skills-catalog/scan.js +221 -0
  143. package/server/lib/skills-catalog/source.js +85 -0
  144. package/server/lib/terminal/DOCUMENTATION.md +114 -0
  145. package/server/lib/terminal/index.js +12 -0
  146. package/server/lib/terminal/input-ws-protocol.js +66 -0
  147. package/server/lib/terminal/input-ws-protocol.test.js +138 -0
  148. package/server/lib/tts/DOCUMENTATION.md +134 -0
  149. package/server/lib/tts/index.js +16 -0
  150. package/server/lib/tts/service.js +162 -0
  151. package/server/lib/tts/summarization.js +171 -0
  152. package/server/lib/tunnels/index.js +166 -0
  153. package/server/lib/tunnels/providers/cloudflare.js +260 -0
  154. package/server/lib/tunnels/registry.js +51 -0
  155. package/server/lib/tunnels/types.js +219 -0
  156. package/server/lib/utils/lru.js +107 -0
  157. package/server/lib/utils/sse.js +121 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * ClawdHub skill scanning
3
+ *
4
+ * Fetches all available skills from the ClawdHub registry
5
+ * and transforms them into SkillsCatalogItem format.
6
+ */
7
+
8
+ import { fetchClawdHubSkills } from './api.js';
9
+
10
+ const MAX_PAGES = 20; // Safety limit to prevent infinite loops
11
+ const CLAWDHUB_PAGE_LIMIT = 25;
12
+
13
+ const mapClawdHubItem = (item) => {
14
+ const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0';
15
+
16
+ return {
17
+ sourceId: 'clawdhub',
18
+ repoSource: 'clawdhub:registry',
19
+ repoSubpath: null,
20
+ gitIdentityId: null,
21
+ skillDir: item.slug,
22
+ skillName: item.slug,
23
+ frontmatterName: item.displayName || item.slug,
24
+ description: item.summary || null,
25
+ installable: true,
26
+ warnings: [],
27
+ // ClawdHub-specific metadata
28
+ clawdhub: {
29
+ slug: item.slug,
30
+ version: latestVersion,
31
+ displayName: item.displayName,
32
+ owner: item.owner?.handle || null,
33
+ downloads: item.stats?.downloads || 0,
34
+ stars: item.stats?.stars || 0,
35
+ versionsCount: item.stats?.versions || 1,
36
+ createdAt: item.createdAt,
37
+ updatedAt: item.updatedAt,
38
+ },
39
+ };
40
+ };
41
+
42
+ /**
43
+ * Scan ClawdHub registry for all available skills
44
+ * @returns {Promise<{ ok: boolean, items?: Array, error?: Object }>}
45
+ */
46
+ export async function scanClawdHub() {
47
+ try {
48
+ const allItems = [];
49
+ let cursor = null;
50
+
51
+ for (let page = 0; page < MAX_PAGES; page++) {
52
+ let items = [];
53
+ let nextCursor = null;
54
+
55
+ try {
56
+ const pageResult = await fetchClawdHubSkills({ cursor });
57
+ items = pageResult.items || [];
58
+ nextCursor = pageResult.nextCursor || null;
59
+ } catch (error) {
60
+ if (page > 0 && allItems.length > 0) {
61
+ console.warn('ClawdHub pagination failed; returning partial results.');
62
+ break;
63
+ }
64
+ throw error;
65
+ }
66
+
67
+ for (const item of items) {
68
+ allItems.push(mapClawdHubItem(item));
69
+ }
70
+
71
+ if (!nextCursor) {
72
+ break;
73
+ }
74
+ cursor = nextCursor;
75
+ }
76
+
77
+ // Sort by downloads (most popular first)
78
+ allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0));
79
+
80
+ return { ok: true, items: allItems };
81
+ } catch (error) {
82
+ console.error('ClawdHub scan error:', error);
83
+ return {
84
+ ok: false,
85
+ error: {
86
+ kind: 'networkError',
87
+ message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub',
88
+ },
89
+ };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Scan a single ClawdHub page (cursor-based)
95
+ * @returns {Promise<{ ok: boolean, items?: Array, nextCursor?: string | null, error?: Object }>}
96
+ */
97
+ export async function scanClawdHubPage({ cursor } = {}) {
98
+ try {
99
+ const { items, nextCursor } = await fetchClawdHubSkills({ cursor });
100
+ const mapped = (items || []).map(mapClawdHubItem).slice(0, CLAWDHUB_PAGE_LIMIT);
101
+ mapped.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0));
102
+ return { ok: true, items: mapped, nextCursor: nextCursor || null };
103
+ } catch (error) {
104
+ console.error('ClawdHub page scan error:', error);
105
+ return {
106
+ ok: false,
107
+ error: {
108
+ kind: 'networkError',
109
+ message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub',
110
+ },
111
+ };
112
+ }
113
+ }
@@ -0,0 +1,21 @@
1
+ export const CURATED_SKILLS_SOURCES = [
2
+ {
3
+ id: 'anthropic',
4
+ label: 'Anthropic',
5
+ description: "Anthropic's public skills repository",
6
+ source: 'anthropics/skills',
7
+ defaultSubpath: 'skills',
8
+ sourceType: 'github',
9
+ },
10
+ {
11
+ id: 'clawdhub',
12
+ label: 'ClawdHub',
13
+ description: 'Community skill registry with vector search',
14
+ source: 'clawdhub:registry',
15
+ sourceType: 'clawdhub',
16
+ },
17
+ ];
18
+
19
+ export function getCuratedSkillsSources() {
20
+ return CURATED_SKILLS_SOURCES.slice();
21
+ }
@@ -0,0 +1,77 @@
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ const DEFAULT_TIMEOUT_MS = 60_000;
7
+ const DEFAULT_MAX_BUFFER = 4 * 1024 * 1024;
8
+
9
+ export function looksLikeAuthError(message) {
10
+ const text = String(message || '');
11
+ return (
12
+ /permission denied/i.test(text) ||
13
+ /publickey/i.test(text) ||
14
+ /could not read from remote repository/i.test(text) ||
15
+ /authentication failed/i.test(text) ||
16
+ /fatal: could not/i.test(text)
17
+ );
18
+ }
19
+
20
+ export async function runGit(args, options = {}) {
21
+ const cwd = options.cwd;
22
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
23
+ const maxBuffer = Number.isFinite(options.maxBuffer) ? options.maxBuffer : DEFAULT_MAX_BUFFER;
24
+
25
+ const identity = options.identity || null;
26
+ const normalizedArgs = Array.isArray(args) ? args.slice() : [];
27
+
28
+ // Non-interactive git (avoid prompts / hangs)
29
+ const env = {
30
+ ...process.env,
31
+ GIT_TERMINAL_PROMPT: '0',
32
+ };
33
+
34
+ if (identity?.sshKey) {
35
+ const sshKeyPath = String(identity.sshKey).trim();
36
+ if (sshKeyPath) {
37
+ // Avoid interactive host key prompts; still safe against changed keys.
38
+ const sshCommand = `ssh -i ${sshKeyPath} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
39
+ normalizedArgs.unshift(`core.sshCommand=${sshCommand}`);
40
+ normalizedArgs.unshift('-c');
41
+ }
42
+ }
43
+
44
+ try {
45
+ const { stdout, stderr } = await execFileAsync('git', normalizedArgs, {
46
+ cwd,
47
+ env,
48
+ windowsHide: true,
49
+ timeout: timeoutMs,
50
+ maxBuffer,
51
+ });
52
+
53
+ return { ok: true, stdout: stdout || '', stderr: stderr || '' };
54
+ } catch (error) {
55
+ const err = error;
56
+ const stdout = typeof err?.stdout === 'string' ? err.stdout : '';
57
+ const stderr = typeof err?.stderr === 'string' ? err.stderr : '';
58
+ const message = err instanceof Error ? err.message : String(err);
59
+
60
+ return {
61
+ ok: false,
62
+ stdout,
63
+ stderr,
64
+ message,
65
+ code: typeof err?.code === 'number' ? err.code : null,
66
+ signal: typeof err?.signal === 'string' ? err.signal : null,
67
+ };
68
+ }
69
+ }
70
+
71
+ export async function assertGitAvailable() {
72
+ const result = await runGit(['--version'], { timeoutMs: 5_000 });
73
+ if (!result.ok) {
74
+ return { ok: false, error: { kind: 'gitUnavailable', message: 'Git is not available in PATH' } };
75
+ }
76
+ return { ok: true };
77
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Skills catalog module
3
+ *
4
+ * Provides skill scanning, installation, and caching from GitHub repositories and ClawdHub.
5
+ */
6
+
7
+ export {
8
+ CURATED_SKILLS_SOURCES,
9
+ getCuratedSkillsSources,
10
+ } from './curated-sources.js';
11
+
12
+ export {
13
+ getCacheKey,
14
+ getCachedScan,
15
+ setCachedScan,
16
+ clearCache,
17
+ } from './cache.js';
18
+
19
+ export {
20
+ parseSkillRepoSource,
21
+ } from './source.js';
22
+
23
+ export {
24
+ scanSkillsRepository,
25
+ } from './scan.js';
26
+
27
+ export {
28
+ installSkillsFromRepository,
29
+ } from './install.js';
30
+
31
+ export {
32
+ scanClawdHub,
33
+ scanClawdHubPage,
34
+ installSkillsFromClawdHub,
35
+ fetchClawdHubSkills,
36
+ fetchClawdHubSkillVersion,
37
+ fetchClawdHubSkillInfo,
38
+ downloadClawdHubSkill,
39
+ isClawdHubSource,
40
+ CLAWDHUB_SOURCE_ID,
41
+ CLAWDHUB_SOURCE_STRING,
42
+ } from './clawdhub/index.js';
@@ -0,0 +1,294 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
6
+ import { parseSkillRepoSource } from './source.js';
7
+
8
+ const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
9
+
10
+ function normalizeUserSkillDir(userSkillDir) {
11
+ if (!userSkillDir) return null;
12
+ const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
13
+ const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills');
14
+ if (userSkillDir === legacySkillDir) {
15
+ if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
16
+ return pluralSkillDir;
17
+ }
18
+ return userSkillDir;
19
+ }
20
+
21
+ function validateSkillName(skillName) {
22
+ if (typeof skillName !== 'string') return false;
23
+ if (skillName.length < 1 || skillName.length > 64) return false;
24
+ return SKILL_NAME_PATTERN.test(skillName);
25
+ }
26
+
27
+ async function safeRm(dir) {
28
+ try {
29
+ await fs.promises.rm(dir, { recursive: true, force: true });
30
+ } catch {
31
+ // ignore
32
+ }
33
+ }
34
+
35
+ function toFsPath(repoDir, repoRelPosixPath) {
36
+ const parts = String(repoRelPosixPath || '')
37
+ .split('/')
38
+ .map((p) => p.trim())
39
+ .filter(Boolean);
40
+ return path.join(repoDir, ...parts);
41
+ }
42
+
43
+ async function ensureDir(dirPath) {
44
+ await fs.promises.mkdir(dirPath, { recursive: true });
45
+ }
46
+
47
+ async function copyDirectoryNoSymlinks(srcDir, dstDir) {
48
+ const srcReal = await fs.promises.realpath(srcDir);
49
+ await ensureDir(dstDir);
50
+
51
+ const walk = async (currentSrc, currentDst) => {
52
+ const entries = await fs.promises.readdir(currentSrc, { withFileTypes: true });
53
+ for (const entry of entries) {
54
+ const nextSrc = path.join(currentSrc, entry.name);
55
+ const nextDst = path.join(currentDst, entry.name);
56
+
57
+ const stat = await fs.promises.lstat(nextSrc);
58
+ if (stat.isSymbolicLink()) {
59
+ throw new Error('Symlinks are not supported in skills');
60
+ }
61
+
62
+ // Guard against traversal: ensure source is still under srcReal
63
+ const nextRealParent = await fs.promises.realpath(path.dirname(nextSrc));
64
+ if (!nextRealParent.startsWith(srcReal)) {
65
+ throw new Error('Invalid source path traversal detected');
66
+ }
67
+
68
+ if (stat.isDirectory()) {
69
+ await ensureDir(nextDst);
70
+ await walk(nextSrc, nextDst);
71
+ continue;
72
+ }
73
+
74
+ if (stat.isFile()) {
75
+ await ensureDir(path.dirname(nextDst));
76
+ await fs.promises.copyFile(nextSrc, nextDst);
77
+ try {
78
+ await fs.promises.chmod(nextDst, stat.mode & 0o777);
79
+ } catch {
80
+ // best-effort
81
+ }
82
+ continue;
83
+ }
84
+
85
+ // Skip other types (sockets, devices, etc.)
86
+ }
87
+ };
88
+
89
+ await walk(srcDir, dstDir);
90
+ }
91
+
92
+ async function cloneRepo({ cloneUrl, identity, tempDir }) {
93
+ const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir];
94
+ const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir];
95
+
96
+ const result = await runGit(preferred, { identity, timeoutMs: 90_000 });
97
+ if (result.ok) return { ok: true };
98
+
99
+ const fallbackResult = await runGit(fallback, { identity, timeoutMs: 90_000 });
100
+ if (fallbackResult.ok) return { ok: true };
101
+
102
+ return {
103
+ ok: false,
104
+ error: fallbackResult,
105
+ };
106
+ }
107
+
108
+ function getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName }) {
109
+ const source = targetSource === 'agents' ? 'agents' : 'opencode';
110
+
111
+ if (scope === 'user') {
112
+ if (source === 'agents') {
113
+ return path.join(os.homedir(), '.agents', 'skills', skillName);
114
+ }
115
+ return path.join(userSkillDir, skillName);
116
+ }
117
+
118
+ if (!workingDirectory) {
119
+ throw new Error('workingDirectory is required for project installs');
120
+ }
121
+
122
+ if (source === 'agents') {
123
+ return path.join(workingDirectory, '.agents', 'skills', skillName);
124
+ }
125
+
126
+ return path.join(workingDirectory, '.opencode', 'skills', skillName);
127
+ }
128
+
129
+ export async function installSkillsFromRepository({
130
+ source,
131
+ subpath,
132
+ defaultSubpath,
133
+ identity,
134
+ scope,
135
+ targetSource,
136
+ workingDirectory,
137
+ userSkillDir,
138
+ selections,
139
+ conflictPolicy,
140
+ conflictDecisions,
141
+ } = {}) {
142
+ const gitCheck = await assertGitAvailable();
143
+ if (!gitCheck.ok) {
144
+ return { ok: false, error: gitCheck.error };
145
+ }
146
+
147
+ const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir);
148
+ if (normalizedUserSkillDir) {
149
+ userSkillDir = normalizedUserSkillDir;
150
+ }
151
+
152
+ if (!userSkillDir) {
153
+ return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
154
+ }
155
+
156
+ if (scope !== 'user' && scope !== 'project') {
157
+ return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
158
+ }
159
+
160
+ if (targetSource !== undefined && targetSource !== 'opencode' && targetSource !== 'agents') {
161
+ return { ok: false, error: { kind: 'invalidSource', message: 'Invalid target source' } };
162
+ }
163
+
164
+ if (scope === 'project' && !workingDirectory) {
165
+ return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
166
+ }
167
+
168
+ const parsed = parseSkillRepoSource(source, { subpath });
169
+ if (!parsed.ok) {
170
+ return { ok: false, error: parsed.error };
171
+ }
172
+
173
+ const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null);
174
+ void effectiveSubpath;
175
+
176
+ const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
177
+
178
+ const requestedDirs = Array.isArray(selections) ? selections.map((s) => String(s?.skillDir || '').trim()).filter(Boolean) : [];
179
+ if (requestedDirs.length === 0) {
180
+ return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } };
181
+ }
182
+
183
+ // Validate names early and compute conflicts without mutating.
184
+ const skillPlans = requestedDirs.map((skillDirPosix) => {
185
+ const skillName = path.posix.basename(skillDirPosix);
186
+ return { skillDirPosix, skillName, installable: validateSkillName(skillName) };
187
+ });
188
+
189
+ const conflicts = [];
190
+ for (const plan of skillPlans) {
191
+ if (!plan.installable) {
192
+ continue;
193
+ }
194
+
195
+ const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.skillName });
196
+ if (fs.existsSync(targetDir)) {
197
+ const decision = conflictDecisions?.[plan.skillName];
198
+ const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
199
+ if (!decision && !hasAutoPolicy) {
200
+ conflicts.push({ skillName: plan.skillName, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
201
+ }
202
+ }
203
+ }
204
+
205
+ if (conflicts.length > 0) {
206
+ return {
207
+ ok: false,
208
+ error: {
209
+ kind: 'conflicts',
210
+ message: 'Some skills already exist in the selected scope',
211
+ conflicts,
212
+ },
213
+ };
214
+ }
215
+
216
+ const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'archcoder-skills-install-'));
217
+
218
+ try {
219
+ const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase });
220
+ if (!cloned.ok) {
221
+ const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim();
222
+ if (looksLikeAuthError(msg)) {
223
+ return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } };
224
+ }
225
+ return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } };
226
+ }
227
+
228
+ // Selective checkout for only requested skill dirs.
229
+ await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--cone'], { identity, timeoutMs: 15_000 });
230
+ const setResult = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...requestedDirs], { identity, timeoutMs: 30_000 });
231
+ if (!setResult.ok) {
232
+ return { ok: false, error: { kind: 'unknown', message: setResult.stderr || setResult.message || 'Failed to configure sparse checkout' } };
233
+ }
234
+
235
+ const checkoutResult = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 });
236
+ if (!checkoutResult.ok) {
237
+ return { ok: false, error: { kind: 'unknown', message: checkoutResult.stderr || checkoutResult.message || 'Failed to checkout repository' } };
238
+ }
239
+
240
+ const installed = [];
241
+ const skipped = [];
242
+
243
+ for (const plan of skillPlans) {
244
+ if (!plan.installable) {
245
+ skipped.push({ skillName: plan.skillName, reason: 'Invalid skill name (directory basename)' });
246
+ continue;
247
+ }
248
+
249
+ const srcDir = toFsPath(tempBase, plan.skillDirPosix);
250
+ const skillMdPath = path.join(srcDir, 'SKILL.md');
251
+ if (!fs.existsSync(skillMdPath)) {
252
+ skipped.push({ skillName: plan.skillName, reason: 'SKILL.md not found in selected directory' });
253
+ continue;
254
+ }
255
+
256
+ const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.skillName });
257
+ const exists = fs.existsSync(targetDir);
258
+
259
+ let decision = conflictDecisions?.[plan.skillName] || null;
260
+ if (!decision) {
261
+ if (exists && conflictPolicy === 'skipAll') decision = 'skip';
262
+ if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite';
263
+ if (!exists) decision = 'overwrite'; // no conflict, proceed
264
+ }
265
+
266
+ if (exists && decision === 'skip') {
267
+ skipped.push({ skillName: plan.skillName, reason: 'Already installed (skipped)' });
268
+ continue;
269
+ }
270
+
271
+ if (exists && decision === 'overwrite') {
272
+ await safeRm(targetDir);
273
+ }
274
+
275
+ // Ensure project parent directories exist
276
+ await ensureDir(path.dirname(targetDir));
277
+
278
+ try {
279
+ await copyDirectoryNoSymlinks(srcDir, targetDir);
280
+ installed.push({ skillName: plan.skillName, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
281
+ } catch (error) {
282
+ await safeRm(targetDir);
283
+ skipped.push({
284
+ skillName: plan.skillName,
285
+ reason: error instanceof Error ? error.message : 'Failed to copy skill files',
286
+ });
287
+ }
288
+ }
289
+
290
+ return { ok: true, installed, skipped };
291
+ } finally {
292
+ await safeRm(tempBase);
293
+ }
294
+ }