orkestrate 0.1.14 → 0.2.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 (73) hide show
  1. package/AGENTS.md +56 -0
  2. package/CONTRIBUTING.md +35 -0
  3. package/README.md +38 -58
  4. package/SECURITY.md +24 -0
  5. package/bin/orkestrate.ts +2 -0
  6. package/docs/concepts.md +119 -0
  7. package/docs/demo-extension-builder.md +82 -0
  8. package/docs/extensions/adapters.md +57 -0
  9. package/docs/extensions/architecture.md +49 -0
  10. package/docs/extensions/introduction.md +26 -0
  11. package/docs/getting-started.md +85 -0
  12. package/docs/hosted-registry.md +90 -0
  13. package/docs/pack-authoring.md +75 -0
  14. package/docs/roadmap.md +59 -0
  15. package/docs/troubleshooting.md +28 -0
  16. package/extensions/opencode-adapter/index.ts +106 -0
  17. package/extensions/opencode-adapter/orkestrate.extension.json +17 -0
  18. package/package.json +40 -33
  19. package/packs/coding/harnesses/opencode/agents/coding.md +8 -0
  20. package/packs/coding/harnesses/opencode/opencode.json +24 -0
  21. package/packs/coding/harnesses/opencode/skills/orkestrate/SKILL.md +57 -0
  22. package/packs/coding/pack.yaml +5 -0
  23. package/packs/extension-builder/harnesses/opencode/agents/extension-builder.md +8 -0
  24. package/packs/extension-builder/harnesses/opencode/opencode.json +31 -0
  25. package/packs/extension-builder/harnesses/opencode/skills/orkestrate/SKILL.md +54 -0
  26. package/packs/extension-builder/harnesses/opencode/skills/orkestrate-pack-author/SKILL.md +59 -0
  27. package/packs/extension-builder/pack.yaml +5 -0
  28. package/src/cli/cmd/extension-submit.ts +267 -0
  29. package/src/cli/cmd/pack-create.ts +43 -0
  30. package/src/cli/cmd/pack.ts +53 -0
  31. package/src/cli/cmd/profile-create.ts +199 -0
  32. package/src/cli/cmd/profile-submit.ts +236 -0
  33. package/src/cli/cmd/profile-validate.ts +5 -0
  34. package/src/cli/cmd/registry.ts +66 -0
  35. package/src/cli/cmd/run.ts +37 -0
  36. package/src/cli/index.ts +163 -0
  37. package/src/cli/tui.ts +355 -0
  38. package/src/cli/ui/welcome.ts +73 -0
  39. package/src/cli.ts +1 -0
  40. package/src/sdk/cross-platform.ts +25 -0
  41. package/src/sdk/extensions/loader.ts +89 -0
  42. package/src/sdk/extensions/manifest.ts +193 -0
  43. package/src/sdk/extensions/types.ts +12 -0
  44. package/src/sdk/harness/sync-slice.ts +57 -0
  45. package/src/sdk/launch/broker.ts +87 -0
  46. package/src/sdk/launch/runner.ts +57 -0
  47. package/src/sdk/launch/terminal.ts +75 -0
  48. package/src/sdk/launch/types.ts +7 -0
  49. package/src/sdk/launch/windows.ts +109 -0
  50. package/src/sdk/packs/catalog.ts +172 -0
  51. package/src/sdk/packs/create.ts +99 -0
  52. package/src/sdk/packs/fs.ts +52 -0
  53. package/src/sdk/packs/github.ts +249 -0
  54. package/src/sdk/packs/paths.ts +19 -0
  55. package/src/sdk/packs/registry.ts +40 -0
  56. package/src/sdk/packs/schema.ts +51 -0
  57. package/src/sdk/packs/store.ts +172 -0
  58. package/src/sdk/profiles/catalog.ts +199 -0
  59. package/src/sdk/profiles/github.ts +177 -0
  60. package/src/sdk/profiles/install.ts +161 -0
  61. package/src/sdk/profiles/load.ts +209 -0
  62. package/src/sdk/profiles/materialize.ts +85 -0
  63. package/src/sdk/profiles/pack.ts +128 -0
  64. package/src/sdk/profiles/schema.ts +201 -0
  65. package/src/sdk/registry.ts +19 -0
  66. package/src/sdk/runs/registry.ts +142 -0
  67. package/src/sdk/runs/types.ts +15 -0
  68. package/src/sdk/types.ts +39 -0
  69. package/src/version.ts +3 -0
  70. package/dist/cli.js +0 -1668
  71. package/dist/cli.js.map +0 -1
  72. package/dist/mcp-entry.js +0 -181
  73. package/dist/mcp-entry.js.map +0 -1
@@ -0,0 +1,172 @@
1
+ import { join } from "node:path";
2
+ import type { Pack } from "./schema";
3
+ import { toPack, parsePackManifest } from "./schema";
4
+ import { listCatalogEntries, pathExists, parseManifestYaml } from "./fs";
5
+ import { bundledCatalogDir, installPackFromDirectory, listInstalledPacks } from "./store";
6
+ import { workspacePacksDir, globalPacksDir, PACK_MANIFEST } from "./paths";
7
+ import { fetchRegistryPackItems, type RegistryItem } from "./registry";
8
+ import {
9
+ installPackFromGitHub,
10
+ parseRegistryMeta,
11
+ resolveGitHubFromRegistryItem,
12
+ } from "./github";
13
+
14
+ export type CatalogEntry = {
15
+ slug: string;
16
+ pack: Pack;
17
+ installed: boolean;
18
+ source: "bundled" | "registry";
19
+ description?: string;
20
+ github?: string;
21
+ };
22
+
23
+ async function isPackInstalled(packId: string): Promise<boolean> {
24
+ if (await pathExists(join(workspacePacksDir, packId, PACK_MANIFEST))) return true;
25
+ if (await pathExists(join(globalPacksDir, packId, PACK_MANIFEST))) return true;
26
+ return false;
27
+ }
28
+
29
+ async function loadBundledEntry(slug: string, path: string): Promise<CatalogEntry> {
30
+ const manifestPath = join(path, PACK_MANIFEST);
31
+ const raw = await Bun.file(manifestPath).text();
32
+ const manifest = parsePackManifest(parseManifestYaml(raw));
33
+ const pack = toPack(manifest, path, manifestPath);
34
+ return {
35
+ slug,
36
+ pack,
37
+ installed: await isPackInstalled(pack.id),
38
+ source: "bundled",
39
+ };
40
+ }
41
+
42
+ function registryEntryToCatalog(item: RegistryItem): CatalogEntry | null {
43
+ const manifest = item.manifest_json;
44
+ if (manifest && typeof manifest === "object" && !Array.isArray(manifest)) {
45
+ const o = manifest as Record<string, unknown>;
46
+ if (typeof o.id === "string" && typeof o.harness === "string") {
47
+ try {
48
+ const parsed = parsePackManifest(manifest);
49
+ const pack = toPack(parsed, item.source_url, item.source_url);
50
+ const gh = resolveGitHubFromRegistryItem(item);
51
+ return {
52
+ slug: item.slug,
53
+ pack,
54
+ installed: false,
55
+ source: "registry",
56
+ description: item.description,
57
+ github: `${gh.owner}/${gh.repo}@${gh.ref}${gh.packPath ? `/${gh.packPath}` : ""}`,
58
+ };
59
+ } catch {
60
+ // fall through — install uses GitHub tree
61
+ }
62
+ }
63
+ }
64
+
65
+ const pack = toPack(
66
+ {
67
+ id: item.slug,
68
+ name: item.name,
69
+ description: item.description,
70
+ harness: "opencode",
71
+ version: item.version,
72
+ },
73
+ item.source_url,
74
+ item.source_url
75
+ );
76
+ const gh = resolveGitHubFromRegistryItem(item);
77
+ return {
78
+ slug: item.slug,
79
+ pack,
80
+ installed: false,
81
+ source: "registry",
82
+ description: item.description,
83
+ github: `${gh.owner}/${gh.repo}@${gh.ref}${gh.packPath ? `/${gh.packPath}` : ""}`,
84
+ };
85
+ }
86
+
87
+ export async function listBundledCatalog(): Promise<CatalogEntry[]> {
88
+ const entries = await listCatalogEntries(bundledCatalogDir);
89
+ const results: CatalogEntry[] = [];
90
+ for (const entry of entries) {
91
+ if (entry.kind !== "pack") continue;
92
+ results.push(await loadBundledEntry(entry.slug, entry.path));
93
+ }
94
+ return results;
95
+ }
96
+
97
+ export async function listRegistryCatalog(): Promise<CatalogEntry[]> {
98
+ let items: RegistryItem[];
99
+ try {
100
+ items = await fetchRegistryPackItems();
101
+ } catch (error) {
102
+ console.warn(error instanceof Error ? error.message : String(error));
103
+ return [];
104
+ }
105
+
106
+ const results: CatalogEntry[] = [];
107
+ for (const item of items) {
108
+ const entry = registryEntryToCatalog(item);
109
+ if (!entry) continue;
110
+ entry.installed = await isPackInstalled(entry.pack.id);
111
+ results.push(entry);
112
+ }
113
+ return results;
114
+ }
115
+
116
+ /** Bundled packs first, then registry (dedupe by pack id). */
117
+ export async function listFullCatalog(): Promise<CatalogEntry[]> {
118
+ const [bundled, remote] = await Promise.all([listBundledCatalog(), listRegistryCatalog()]);
119
+ const seen = new Set<string>();
120
+ const merged: CatalogEntry[] = [];
121
+ for (const entry of [...bundled, ...remote]) {
122
+ if (seen.has(entry.pack.id)) continue;
123
+ seen.add(entry.pack.id);
124
+ merged.push(entry);
125
+ }
126
+ return merged.sort((a, b) => a.pack.id.localeCompare(b.pack.id));
127
+ }
128
+
129
+ export async function installCatalogPack(
130
+ slug: string,
131
+ options?: { target?: "workspace" | "global"; overwrite?: boolean }
132
+ ): Promise<Pack> {
133
+ const safeSlug = slug.replace(/[^a-z0-9-_]/gi, "");
134
+
135
+ const entries = await listCatalogEntries(bundledCatalogDir);
136
+ const hit = entries.find((e) => e.slug === safeSlug || e.slug === slug);
137
+ if (hit?.kind === "pack") {
138
+ return installPackFromDirectory(hit.path, {
139
+ target: options?.target ?? "workspace",
140
+ overwrite: options?.overwrite,
141
+ });
142
+ }
143
+
144
+ let items: RegistryItem[];
145
+ try {
146
+ items = await fetchRegistryPackItems();
147
+ } catch (error) {
148
+ throw new Error(
149
+ `Pack "${slug}" is not bundled and registry is unavailable: ${
150
+ error instanceof Error ? error.message : String(error)
151
+ }`
152
+ );
153
+ }
154
+
155
+ const item = items.find((i) => i.slug === slug || i.slug === safeSlug);
156
+ if (!item) {
157
+ throw new Error(`Pack "${slug}" not found in bundled catalog or registry.`);
158
+ }
159
+
160
+ const gh = resolveGitHubFromRegistryItem(item);
161
+ const meta = parseRegistryMeta(item.manifest_json);
162
+
163
+ return installPackFromGitHub(item.source_url, {
164
+ ref: meta.ref ?? gh.ref,
165
+ packPath: meta.packPath ?? gh.packPath,
166
+ slug: item.slug,
167
+ target: options?.target,
168
+ overwrite: options?.overwrite,
169
+ });
170
+ }
171
+
172
+ export { listInstalledPacks, seedBundledToGlobalIfMissing } from "./store";
@@ -0,0 +1,99 @@
1
+ import { cp, mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { assertPackId, type PackManifest } from "./schema";
4
+ import { bundledCatalogDir } from "./store";
5
+ import { pathExists } from "./fs";
6
+ import { globalPacksDir, workspacePacksDir } from "./paths";
7
+
8
+ export type CreatePackOptions = {
9
+ id: string;
10
+ description?: string;
11
+ template?: string;
12
+ target?: "workspace" | "global";
13
+ };
14
+
15
+ function yamlEscape(value: string): string {
16
+ if (/[:#\n]/.test(value) || value.startsWith(" ")) {
17
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
18
+ }
19
+ return value;
20
+ }
21
+
22
+ function manifestYaml(manifest: PackManifest): string {
23
+ const lines = [
24
+ `id: ${manifest.id}`,
25
+ `name: ${manifest.name}`,
26
+ `description: ${yamlEscape(manifest.description)}`,
27
+ `version: "${manifest.version ?? "0.1.0"}"`,
28
+ `harness: ${manifest.harness}`,
29
+ ];
30
+ return lines.join("\n") + "\n";
31
+ }
32
+
33
+ async function replaceInTree(root: string, from: string, to: string): Promise<void> {
34
+ const entries = await readdir(root, { withFileTypes: true });
35
+ for (const entry of entries) {
36
+ const full = join(root, entry.name);
37
+ if (entry.isDirectory()) {
38
+ await replaceInTree(full, from, to);
39
+ continue;
40
+ }
41
+ const text = await readFile(full, "utf-8");
42
+ if (!text.includes(from)) continue;
43
+ await writeFile(full, text.split(from).join(to), "utf-8");
44
+ }
45
+ }
46
+
47
+ export async function createPackFromTemplate(options: CreatePackOptions): Promise<string> {
48
+ const id = assertPackId(options.id);
49
+ const template = assertPackId(options.template ?? "coding");
50
+ const templateRoot = join(bundledCatalogDir, template);
51
+ if (!(await pathExists(templateRoot))) {
52
+ throw new Error(`Template pack "${template}" not found in ${bundledCatalogDir}`);
53
+ }
54
+
55
+ const target = options.target ?? "workspace";
56
+ const destBase = target === "global" ? globalPacksDir : workspacePacksDir;
57
+ const dest = join(destBase, id);
58
+
59
+ if (await pathExists(dest)) {
60
+ throw new Error(`Pack "${id}" already exists at ${dest}`);
61
+ }
62
+
63
+ await mkdir(destBase, { recursive: true });
64
+ await cp(templateRoot, dest, { recursive: true });
65
+
66
+ const description =
67
+ options.description ??
68
+ (template === id ? `Pack ${id}` : `${template} pack customized as ${id}`);
69
+
70
+ const manifest: PackManifest = {
71
+ id,
72
+ name: id,
73
+ description,
74
+ version: "0.1.0",
75
+ harness: "opencode",
76
+ };
77
+ await writeFile(join(dest, "pack.yaml"), manifestYaml(manifest), "utf-8");
78
+
79
+ if (template !== id) {
80
+ const agentDir = join(dest, "harnesses", "opencode", "agents");
81
+ const oldAgent = join(agentDir, `${template}.md`);
82
+ const newAgent = join(agentDir, `${id}.md`);
83
+ if (await pathExists(oldAgent)) {
84
+ await rename(oldAgent, newAgent);
85
+ }
86
+ await replaceInTree(join(dest, "harnesses", "opencode"), template, id);
87
+ }
88
+
89
+ const infoPath = join(dest, "info.md");
90
+ if (!(await pathExists(infoPath))) {
91
+ await writeFile(
92
+ infoPath,
93
+ `# ${id}\n\n${description}\n`,
94
+ "utf-8"
95
+ );
96
+ }
97
+
98
+ return dest;
99
+ }
@@ -0,0 +1,52 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { PACK_MANIFEST } from "./paths";
4
+
5
+ export function parseManifestYaml(raw: string): unknown {
6
+ if (typeof (Bun as { YAML?: { parse: (s: string) => unknown } }).YAML?.parse === "function") {
7
+ return Bun.YAML.parse(raw);
8
+ }
9
+ return JSON.parse(raw);
10
+ }
11
+
12
+ export async function pathExists(path: string): Promise<boolean> {
13
+ try {
14
+ await stat(path);
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export async function findPackManifestInDir(dir: string): Promise<string | null> {
22
+ const direct = join(dir, PACK_MANIFEST);
23
+ if (await pathExists(direct)) return direct;
24
+ return null;
25
+ }
26
+
27
+ export type CatalogEntryKind = {
28
+ slug: string;
29
+ kind: "pack" | "legacy-profile";
30
+ path: string;
31
+ };
32
+
33
+ export async function listCatalogEntries(catalogDir: string): Promise<CatalogEntryKind[]> {
34
+ const entries: CatalogEntryKind[] = [];
35
+ try {
36
+ const names = await readdir(catalogDir);
37
+ for (const name of names) {
38
+ const full = join(catalogDir, name);
39
+ const info = await stat(full);
40
+ if (info.isDirectory()) {
41
+ if (await findPackManifestInDir(full)) {
42
+ entries.push({ slug: name, kind: "pack", path: full });
43
+ } else if (await pathExists(join(full, "profile.json"))) {
44
+ entries.push({ slug: name, kind: "legacy-profile", path: full });
45
+ }
46
+ }
47
+ }
48
+ } catch {
49
+ return [];
50
+ }
51
+ return entries.sort((a, b) => a.slug.localeCompare(b.slug));
52
+ }
@@ -0,0 +1,249 @@
1
+ import { cp, mkdir, readdir, rm, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { homedir } from "node:os";
5
+ import { findPackManifestInDir, pathExists } from "./fs";
6
+ import { PACK_MANIFEST } from "./paths";
7
+ import type { Pack } from "./schema";
8
+
9
+ export type GitHubSource = {
10
+ owner: string;
11
+ repo: string;
12
+ ref: string;
13
+ packPath: string;
14
+ webUrl: string;
15
+ };
16
+
17
+ export function packsCacheDir(): string {
18
+ return join(homedir(), ".orkestrate", "registry-cache");
19
+ }
20
+
21
+ export function parseGitHubSource(
22
+ sourceUrl: string,
23
+ options?: { ref?: string; packPath?: string }
24
+ ): GitHubSource {
25
+ let url: URL;
26
+ try {
27
+ url = new URL(sourceUrl);
28
+ } catch {
29
+ throw new Error(`Invalid source URL: ${sourceUrl}`);
30
+ }
31
+
32
+ if (url.hostname !== "github.com") {
33
+ throw new Error("Only github.com source URLs are supported");
34
+ }
35
+
36
+ const parts = url.pathname.split("/").filter(Boolean);
37
+ if (parts.length < 2) {
38
+ throw new Error(`Invalid GitHub URL: ${sourceUrl}`);
39
+ }
40
+
41
+ const owner = parts[0]!;
42
+ const repo = parts[1]!.replace(/\.git$/, "");
43
+
44
+ let ref = options?.ref ?? "main";
45
+ let packPath = options?.packPath ?? "";
46
+
47
+ if (parts[2] === "tree" || parts[2] === "blob") {
48
+ ref = parts[3] ?? ref;
49
+ packPath = parts.slice(4).join("/");
50
+ }
51
+
52
+ if (options?.packPath) {
53
+ packPath = options.packPath;
54
+ }
55
+
56
+ return {
57
+ owner,
58
+ repo,
59
+ ref,
60
+ packPath: packPath.replace(/^\/+|\/+$/g, ""),
61
+ webUrl: `https://github.com/${owner}/${repo}`,
62
+ };
63
+ }
64
+
65
+ export function parseRegistryMeta(manifest: unknown): { ref?: string; packPath?: string } {
66
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
67
+ return {};
68
+ }
69
+ const block = (manifest as Record<string, unknown>).orkestrate;
70
+ if (!block || typeof block !== "object" || Array.isArray(block)) {
71
+ return {};
72
+ }
73
+ const o = block as Record<string, unknown>;
74
+ return {
75
+ ref: typeof o.ref === "string" ? o.ref : undefined,
76
+ packPath: typeof o.packPath === "string" ? o.packPath : undefined,
77
+ };
78
+ }
79
+
80
+ export function resolveGitHubFromRegistryItem(item: {
81
+ source_url: string;
82
+ version?: string;
83
+ manifest_json?: unknown;
84
+ slug: string;
85
+ }): GitHubSource {
86
+ const meta = parseRegistryMeta(item.manifest_json);
87
+ const ref = meta.ref ?? item.version ?? "main";
88
+ return parseGitHubSource(item.source_url, { ref, packPath: meta.packPath });
89
+ }
90
+
91
+ async function findPackRoot(cloneRoot: string, packPath: string): Promise<string> {
92
+ const trimmed = packPath.replace(/^\/+|\/+$/g, "");
93
+ if (trimmed) {
94
+ const candidate = join(cloneRoot, trimmed);
95
+ if (await findPackManifestInDir(candidate)) {
96
+ return candidate;
97
+ }
98
+ throw new Error(`Pack path "${packPath}" does not contain ${PACK_MANIFEST}`);
99
+ }
100
+
101
+ if (await findPackManifestInDir(cloneRoot)) {
102
+ return cloneRoot;
103
+ }
104
+
105
+ const entries = await readdir(cloneRoot);
106
+ for (const entry of entries) {
107
+ const full = join(cloneRoot, entry);
108
+ const info = await stat(full);
109
+ if (info.isDirectory() && (await findPackManifestInDir(full))) {
110
+ return full;
111
+ }
112
+ }
113
+
114
+ throw new Error(`Repository does not contain a pack (${PACK_MANIFEST} not found)`);
115
+ }
116
+
117
+ async function commandExists(command: string): Promise<boolean> {
118
+ const proc = Bun.spawnSync({
119
+ cmd: [command, "--version"],
120
+ stdout: "ignore",
121
+ stderr: "ignore",
122
+ });
123
+ return proc.exitCode === 0;
124
+ }
125
+
126
+ async function cloneWithGit(source: GitHubSource, dest: string): Promise<string> {
127
+ const cloneUrl = `${source.webUrl}.git`;
128
+ const proc = Bun.spawn(
129
+ ["git", "clone", "--depth", "1", "--branch", source.ref, "--single-branch", cloneUrl, dest],
130
+ { stdout: "pipe", stderr: "pipe" }
131
+ );
132
+ const code = await proc.exited;
133
+ if (code !== 0) {
134
+ const err = await new Response(proc.stderr).text();
135
+ throw new Error(`git clone failed: ${err.trim() || `exit ${code}`}`);
136
+ }
137
+ return findPackRoot(dest, source.packPath);
138
+ }
139
+
140
+ async function downloadTarball(source: GitHubSource, dest: string): Promise<string> {
141
+ const apiUrl = `https://api.github.com/repos/${source.owner}/${source.repo}/tarball/${source.ref}`;
142
+ const response = await fetch(apiUrl, {
143
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "orkestrate-cli" },
144
+ redirect: "follow",
145
+ });
146
+
147
+ if (!response.ok) {
148
+ throw new Error(`GitHub archive download failed: ${response.status} ${response.statusText}`);
149
+ }
150
+
151
+ const archivePath = join(dest, "archive.tar.gz");
152
+ await mkdir(dest, { recursive: true });
153
+ await Bun.write(archivePath, await response.arrayBuffer());
154
+
155
+ const extractDir = join(dest, "extract");
156
+ await mkdir(extractDir, { recursive: true });
157
+
158
+ const tar = Bun.spawn(["tar", "-xzf", archivePath, "-C", extractDir], {
159
+ stdout: "pipe",
160
+ stderr: "pipe",
161
+ });
162
+ const tarCode = await tar.exited;
163
+ if (tarCode !== 0) {
164
+ const err = await new Response(tar.stderr).text();
165
+ throw new Error(`tar extract failed: ${err.trim() || "install git or tar"}`);
166
+ }
167
+
168
+ const top = await readdir(extractDir);
169
+ if (top.length === 0) {
170
+ throw new Error("GitHub archive was empty");
171
+ }
172
+ const root = join(extractDir, top[0]!);
173
+ return findPackRoot(root, source.packPath);
174
+ }
175
+
176
+ export async function fetchGitHubPackRoot(source: GitHubSource): Promise<string> {
177
+ const work = join(tmpdir(), `orkestrate-fetch-${source.owner}-${source.repo}-${Date.now()}`);
178
+ await mkdir(work, { recursive: true });
179
+
180
+ try {
181
+ if (await commandExists("git")) {
182
+ try {
183
+ return await cloneWithGit(source, join(work, "repo"));
184
+ } catch {
185
+ // fall through to tarball
186
+ }
187
+ }
188
+ return await downloadTarball(source, work);
189
+ } catch (error) {
190
+ await rm(work, { recursive: true, force: true }).catch(() => {});
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ export async function installPackFromGitHub(
196
+ sourceUrl: string,
197
+ options: {
198
+ ref?: string;
199
+ packPath?: string;
200
+ slug?: string;
201
+ target?: "workspace" | "global";
202
+ overwrite?: boolean;
203
+ } = {}
204
+ ): Promise<Pack> {
205
+ const { installPackFromDirectory } = await import("./store");
206
+ const source = parseGitHubSource(sourceUrl, {
207
+ ref: options.ref,
208
+ packPath: options.packPath,
209
+ });
210
+
211
+ const cacheSlug = (options.slug ?? `${source.owner}-${source.repo}-${source.ref}`).replace(
212
+ /[^a-z0-9-_]+/gi,
213
+ "-"
214
+ );
215
+ const cacheDir = join(packsCacheDir(), cacheSlug);
216
+
217
+ if (await pathExists(cacheDir)) {
218
+ await rm(cacheDir, { recursive: true, force: true });
219
+ }
220
+ await mkdir(packsCacheDir(), { recursive: true });
221
+
222
+ const packRoot = await fetchGitHubPackRoot(source);
223
+ const parent = join(packRoot, "..");
224
+ await cp(packRoot, cacheDir, { recursive: true, force: true });
225
+ await rm(parent, { recursive: true, force: true }).catch(() => {});
226
+
227
+ const installed = await installPackFromDirectory(cacheDir, {
228
+ target: options.target ?? "workspace",
229
+ overwrite: options.overwrite ?? false,
230
+ });
231
+
232
+ await writeInstallRecord(installed.packRoot, {
233
+ source: "registry",
234
+ sourceUrl,
235
+ ref: source.ref,
236
+ packPath: source.packPath,
237
+ slug: cacheSlug,
238
+ installedAt: new Date().toISOString(),
239
+ });
240
+
241
+ return installed;
242
+ }
243
+
244
+ async function writeInstallRecord(packDir: string, record: Record<string, unknown>): Promise<void> {
245
+ await Bun.write(
246
+ join(packDir, ".orkestrate-install.json"),
247
+ JSON.stringify(record, null, 2) + "\n"
248
+ );
249
+ }
@@ -0,0 +1,19 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+
4
+ export const PACK_MANIFEST = "pack.yaml";
5
+ export const HARNESS_DIR = "harnesses";
6
+
7
+ export const workspacePacksDir = join(process.cwd(), ".orkestrate", "packs");
8
+ export const globalPacksDir = join(homedir(), ".orkestrate", "packs");
9
+ export const workspaceRunsDir = join(process.cwd(), ".orkestrate", "runs");
10
+ /** Persistent OpenCode home per pack in this workspace (sessions survive relaunches). */
11
+ export const workspacePackHomesDir = join(process.cwd(), ".orkestrate", "pack-homes");
12
+
13
+ export function packHomePath(packId: string): string {
14
+ return join(workspacePackHomesDir, packId, "home");
15
+ }
16
+
17
+ export function harnessSliceDir(packRoot: string, harness: string): string {
18
+ return join(packRoot, HARNESS_DIR, harness);
19
+ }
@@ -0,0 +1,40 @@
1
+ export const REGISTRY_URL =
2
+ process.env.ORKESTRATE_REGISTRY_URL || "https://orkestrate.space/api/registry";
3
+
4
+ export type RegistryItem = {
5
+ id: string;
6
+ slug: string;
7
+ kind: string;
8
+ name: string;
9
+ description: string;
10
+ source_url: string;
11
+ manifest_url: string | null;
12
+ version: string;
13
+ manifest_json: unknown;
14
+ };
15
+
16
+ const PACK_KINDS = new Set(["profile-pack", "pack", "skill-pack"]);
17
+
18
+ export async function fetchRegistry(): Promise<RegistryItem[]> {
19
+ try {
20
+ const response = await fetch(REGISTRY_URL, {
21
+ headers: { Accept: "application/json" },
22
+ });
23
+ if (!response.ok) {
24
+ throw new Error(`Registry returned ${response.status} ${response.statusText}`);
25
+ }
26
+ return (await response.json()) as RegistryItem[];
27
+ } catch (error) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ throw new Error(`Could not reach registry at ${REGISTRY_URL}: ${message}`);
30
+ }
31
+ }
32
+
33
+ export function isInstallablePackKind(kind: string): boolean {
34
+ return PACK_KINDS.has(kind);
35
+ }
36
+
37
+ export async function fetchRegistryPackItems(): Promise<RegistryItem[]> {
38
+ const items = await fetchRegistry();
39
+ return items.filter((item) => isInstallablePackKind(item.kind) && item.source_url);
40
+ }
@@ -0,0 +1,51 @@
1
+ export type PackManifest = {
2
+ id: string;
3
+ name: string;
4
+ description: string;
5
+ version?: string;
6
+ harness: string;
7
+ author?: string;
8
+ tags?: string[];
9
+ };
10
+
11
+ export type Pack = PackManifest & {
12
+ packRoot: string;
13
+ manifestPath: string;
14
+ };
15
+
16
+ export function assertPackId(id: string): string {
17
+ if (!/^[a-z0-9][a-z0-9-_]*$/.test(id)) {
18
+ throw new Error('Pack id must use lowercase letters, numbers, "-", or "_"');
19
+ }
20
+ return id;
21
+ }
22
+
23
+ export function parsePackManifest(value: unknown): PackManifest {
24
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
25
+ throw new Error("pack.yaml must be an object");
26
+ }
27
+ const o = value as Record<string, unknown>;
28
+ const id = assertPackId(String(o.id ?? o.name ?? ""));
29
+ const name = typeof o.name === "string" && o.name.length > 0 ? o.name : id;
30
+ const description = typeof o.description === "string" ? o.description : "";
31
+ if (!description) {
32
+ throw new Error('pack.yaml field "description" is required');
33
+ }
34
+ const harness = typeof o.harness === "string" ? o.harness : "";
35
+ if (!harness || !/^[a-z0-9-]+$/.test(harness)) {
36
+ throw new Error('pack.yaml field "harness" must be a lowercase harness id (e.g. opencode)');
37
+ }
38
+ return {
39
+ id,
40
+ name,
41
+ description,
42
+ harness,
43
+ version: typeof o.version === "string" ? o.version : undefined,
44
+ author: typeof o.author === "string" ? o.author : undefined,
45
+ tags: Array.isArray(o.tags) && o.tags.every((t) => typeof t === "string") ? (o.tags as string[]) : undefined,
46
+ };
47
+ }
48
+
49
+ export function toPack(manifest: PackManifest, packRoot: string, manifestPath: string): Pack {
50
+ return { ...manifest, packRoot, manifestPath };
51
+ }