ocx 1.4.0 → 1.4.1

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.
package/dist/index.js CHANGED
@@ -9814,6 +9814,120 @@ var coerce = {
9814
9814
  date: (arg) => ZodDate.create({ ...arg, coerce: true })
9815
9815
  };
9816
9816
  var NEVER = INVALID;
9817
+ // src/schemas/registry.ts
9818
+ import { isAbsolute, normalize } from "path";
9819
+
9820
+ // src/utils/errors.ts
9821
+ var EXIT_CODES = {
9822
+ SUCCESS: 0,
9823
+ GENERAL: 1,
9824
+ NOT_FOUND: 66,
9825
+ NETWORK: 69,
9826
+ CONFIG: 78,
9827
+ INTEGRITY: 1
9828
+ };
9829
+
9830
+ class OCXError extends Error {
9831
+ code;
9832
+ exitCode;
9833
+ constructor(message, code, exitCode = EXIT_CODES.GENERAL) {
9834
+ super(message);
9835
+ this.code = code;
9836
+ this.exitCode = exitCode;
9837
+ this.name = "OCXError";
9838
+ }
9839
+ }
9840
+
9841
+ class NotFoundError extends OCXError {
9842
+ constructor(message) {
9843
+ super(message, "NOT_FOUND", EXIT_CODES.NOT_FOUND);
9844
+ this.name = "NotFoundError";
9845
+ }
9846
+ }
9847
+
9848
+ class NetworkError extends OCXError {
9849
+ constructor(message) {
9850
+ super(message, "NETWORK_ERROR", EXIT_CODES.NETWORK);
9851
+ this.name = "NetworkError";
9852
+ }
9853
+ }
9854
+
9855
+ class ConfigError extends OCXError {
9856
+ constructor(message) {
9857
+ super(message, "CONFIG_ERROR", EXIT_CODES.CONFIG);
9858
+ this.name = "ConfigError";
9859
+ }
9860
+ }
9861
+
9862
+ class ValidationError extends OCXError {
9863
+ constructor(message) {
9864
+ super(message, "VALIDATION_ERROR", EXIT_CODES.GENERAL);
9865
+ this.name = "ValidationError";
9866
+ }
9867
+ }
9868
+
9869
+ class ConflictError extends OCXError {
9870
+ constructor(message) {
9871
+ super(message, "CONFLICT", EXIT_CODES.GENERAL);
9872
+ this.name = "ConflictError";
9873
+ }
9874
+ }
9875
+
9876
+ class IntegrityError extends OCXError {
9877
+ constructor(component, expected, found) {
9878
+ const message = `Integrity verification failed for "${component}"
9879
+ ` + ` Expected: ${expected}
9880
+ ` + ` Found: ${found}
9881
+
9882
+ ` + `The registry content has changed since this component was locked.
9883
+ ` + `Use 'ocx update ${component}' to intentionally update this component.`;
9884
+ super(message, "INTEGRITY_ERROR", EXIT_CODES.INTEGRITY);
9885
+ this.name = "IntegrityError";
9886
+ }
9887
+ }
9888
+
9889
+ class SelfUpdateError extends OCXError {
9890
+ constructor(message) {
9891
+ super(message, "UPDATE_ERROR", EXIT_CODES.GENERAL);
9892
+ this.name = "SelfUpdateError";
9893
+ }
9894
+ }
9895
+
9896
+ class OcxConfigError extends OCXError {
9897
+ constructor(message) {
9898
+ super(message, "CONFIG_ERROR", EXIT_CODES.CONFIG);
9899
+ this.name = "OcxConfigError";
9900
+ }
9901
+ }
9902
+
9903
+ class ProfileNotFoundError extends OCXError {
9904
+ constructor(name) {
9905
+ super(`Profile "${name}" not found`, "NOT_FOUND", EXIT_CODES.NOT_FOUND);
9906
+ this.name = "ProfileNotFoundError";
9907
+ }
9908
+ }
9909
+
9910
+ class ProfileExistsError extends OCXError {
9911
+ constructor(name) {
9912
+ super(`Profile "${name}" already exists`, "CONFLICT", EXIT_CODES.GENERAL);
9913
+ this.name = "ProfileExistsError";
9914
+ }
9915
+ }
9916
+
9917
+ class InvalidProfileNameError extends OCXError {
9918
+ constructor(name, reason) {
9919
+ super(`Invalid profile name "${name}": ${reason}`, "VALIDATION_ERROR", EXIT_CODES.GENERAL);
9920
+ this.name = "InvalidProfileNameError";
9921
+ }
9922
+ }
9923
+
9924
+ class ProfilesNotInitializedError extends OCXError {
9925
+ constructor() {
9926
+ super("Profiles not initialized. Run 'ocx profile add default' first.", "NOT_FOUND", EXIT_CODES.NOT_FOUND);
9927
+ this.name = "ProfilesNotInitializedError";
9928
+ }
9929
+ }
9930
+
9817
9931
  // src/schemas/registry.ts
9818
9932
  var npmSpecifierSchema = exports_external.string().refine((val) => val.startsWith("npm:"), {
9819
9933
  message: 'npm specifier must start with "npm:" prefix'
@@ -9860,8 +9974,10 @@ var componentTypeSchema = exports_external.enum([
9860
9974
  "ocx:plugin",
9861
9975
  "ocx:command",
9862
9976
  "ocx:tool",
9863
- "ocx:bundle"
9977
+ "ocx:bundle",
9978
+ "ocx:profile"
9864
9979
  ]);
9980
+ var profileTargetPathSchema = exports_external.enum(["ocx.jsonc", "opencode.jsonc", "AGENTS.md"]);
9865
9981
  var targetPathSchema = exports_external.string().refine((path) => path.startsWith(".opencode/"), {
9866
9982
  message: 'Target path must start with ".opencode/"'
9867
9983
  }).refine((path) => {
@@ -9901,7 +10017,7 @@ var mcpServerObjectSchema = exports_external.object({
9901
10017
  var mcpServerRefSchema = exports_external.union([exports_external.string(), mcpServerObjectSchema]);
9902
10018
  var componentFileObjectSchema = exports_external.object({
9903
10019
  path: exports_external.string().min(1, "File path cannot be empty"),
9904
- target: targetPathSchema
10020
+ target: exports_external.string().min(1, "Target path cannot be empty")
9905
10021
  });
9906
10022
  var componentFileSchema = exports_external.union([
9907
10023
  exports_external.string().min(1, "File path cannot be empty"),
@@ -9993,16 +10109,56 @@ var componentManifestSchema = exports_external.object({
9993
10109
  npmDevDependencies: exports_external.array(exports_external.string()).optional(),
9994
10110
  opencode: opencodeConfigSchema.optional()
9995
10111
  });
10112
+ function validateSafePath(filePath) {
10113
+ if (isAbsolute(filePath)) {
10114
+ throw new ValidationError(`Invalid path: "${filePath}" - absolute paths not allowed`);
10115
+ }
10116
+ if (filePath.startsWith("~")) {
10117
+ throw new ValidationError(`Invalid path: "${filePath}" - home directory paths not allowed`);
10118
+ }
10119
+ const normalized = normalize(filePath);
10120
+ if (normalized.startsWith("..")) {
10121
+ throw new ValidationError(`Invalid path: "${filePath}" - path traversal not allowed`);
10122
+ }
10123
+ }
9996
10124
  function inferTargetPath(sourcePath) {
9997
10125
  return `.opencode/${sourcePath}`;
9998
10126
  }
9999
- function normalizeFile(file) {
10127
+ function validateFileTarget(target, componentType) {
10128
+ const isProfile = componentType === "ocx:profile";
10129
+ if (isProfile) {
10130
+ const isProfileFile = profileTargetPathSchema.safeParse(target).success;
10131
+ const isOpencodeTarget = target.startsWith(".opencode/");
10132
+ if (!isProfileFile && !isOpencodeTarget) {
10133
+ throw new ValidationError(`Invalid profile target: "${target}". ` + `Must be a profile file (ocx.jsonc, opencode.jsonc, AGENTS.md) or start with ".opencode/"`);
10134
+ }
10135
+ if (isOpencodeTarget) {
10136
+ const parseResult = targetPathSchema.safeParse(target);
10137
+ if (!parseResult.success) {
10138
+ throw new ValidationError(`Invalid embedded target: "${target}". ${parseResult.error.errors[0]?.message}`);
10139
+ }
10140
+ }
10141
+ } else {
10142
+ const parseResult = targetPathSchema.safeParse(target);
10143
+ if (!parseResult.success) {
10144
+ throw new ValidationError(`Invalid target: "${target}". ${parseResult.error.errors[0]?.message}`);
10145
+ }
10146
+ }
10147
+ }
10148
+ function normalizeFile(file, componentType) {
10149
+ const isProfile = componentType === "ocx:profile";
10000
10150
  if (typeof file === "string") {
10151
+ validateSafePath(file);
10152
+ const target = isProfile ? file : inferTargetPath(file);
10153
+ validateFileTarget(target, componentType);
10001
10154
  return {
10002
10155
  path: file,
10003
- target: inferTargetPath(file)
10156
+ target
10004
10157
  };
10005
10158
  }
10159
+ validateSafePath(file.path);
10160
+ validateSafePath(file.target);
10161
+ validateFileTarget(file.target, componentType);
10006
10162
  return file;
10007
10163
  }
10008
10164
  function normalizeMcpServer(server) {
@@ -10028,7 +10184,7 @@ function normalizeComponentManifest(manifest) {
10028
10184
  }
10029
10185
  return {
10030
10186
  ...manifest,
10031
- files: manifest.files.map(normalizeFile),
10187
+ files: manifest.files.map((file) => normalizeFile(file, manifest.type)),
10032
10188
  opencode: normalizedOpencode
10033
10189
  };
10034
10190
  }
@@ -10128,9 +10284,15 @@ function findOcxConfig(cwd) {
10128
10284
  }
10129
10285
  return { path: dotOpencodePath, exists: false };
10130
10286
  }
10131
- function findOcxLock(cwd) {
10287
+ function findOcxLock(cwd, options) {
10132
10288
  const dotOpencodePath = path.join(cwd, LOCAL_CONFIG_DIR, LOCK_FILE);
10133
10289
  const rootPath = path.join(cwd, LOCK_FILE);
10290
+ if (options?.isFlattened) {
10291
+ if (existsSync(rootPath)) {
10292
+ return { path: rootPath, exists: true };
10293
+ }
10294
+ return { path: rootPath, exists: false };
10295
+ }
10134
10296
  if (existsSync(dotOpencodePath)) {
10135
10297
  return { path: dotOpencodePath, exists: true };
10136
10298
  }
@@ -10160,8 +10322,8 @@ async function writeOcxConfig(cwd, config, existingPath) {
10160
10322
  const content = JSON.stringify(config, null, 2);
10161
10323
  await Bun.write(configPath, content);
10162
10324
  }
10163
- async function readOcxLock(cwd) {
10164
- const { path: lockPath, exists } = findOcxLock(cwd);
10325
+ async function readOcxLock(cwd, options) {
10326
+ const { path: lockPath, exists } = findOcxLock(cwd, options);
10165
10327
  if (!exists) {
10166
10328
  return null;
10167
10329
  }
@@ -10177,124 +10339,13 @@ async function writeOcxLock(cwd, lock, existingPath) {
10177
10339
  await Bun.write(lockPath, content);
10178
10340
  }
10179
10341
 
10180
- // src/utils/errors.ts
10181
- var EXIT_CODES = {
10182
- SUCCESS: 0,
10183
- GENERAL: 1,
10184
- NOT_FOUND: 66,
10185
- NETWORK: 69,
10186
- CONFIG: 78,
10187
- INTEGRITY: 1
10188
- };
10189
-
10190
- class OCXError extends Error {
10191
- code;
10192
- exitCode;
10193
- constructor(message, code, exitCode = EXIT_CODES.GENERAL) {
10194
- super(message);
10195
- this.code = code;
10196
- this.exitCode = exitCode;
10197
- this.name = "OCXError";
10198
- }
10199
- }
10200
-
10201
- class NotFoundError extends OCXError {
10202
- constructor(message) {
10203
- super(message, "NOT_FOUND", EXIT_CODES.NOT_FOUND);
10204
- this.name = "NotFoundError";
10205
- }
10206
- }
10207
-
10208
- class NetworkError extends OCXError {
10209
- constructor(message) {
10210
- super(message, "NETWORK_ERROR", EXIT_CODES.NETWORK);
10211
- this.name = "NetworkError";
10212
- }
10213
- }
10214
-
10215
- class ConfigError extends OCXError {
10216
- constructor(message) {
10217
- super(message, "CONFIG_ERROR", EXIT_CODES.CONFIG);
10218
- this.name = "ConfigError";
10219
- }
10220
- }
10221
-
10222
- class ValidationError extends OCXError {
10223
- constructor(message) {
10224
- super(message, "VALIDATION_ERROR", EXIT_CODES.GENERAL);
10225
- this.name = "ValidationError";
10226
- }
10227
- }
10228
-
10229
- class ConflictError extends OCXError {
10230
- constructor(message) {
10231
- super(message, "CONFLICT", EXIT_CODES.GENERAL);
10232
- this.name = "ConflictError";
10233
- }
10234
- }
10235
-
10236
- class IntegrityError extends OCXError {
10237
- constructor(component, expected, found) {
10238
- const message = `Integrity verification failed for "${component}"
10239
- ` + ` Expected: ${expected}
10240
- ` + ` Found: ${found}
10241
-
10242
- ` + `The registry content has changed since this component was locked.
10243
- ` + `Use 'ocx update ${component}' to intentionally update this component.`;
10244
- super(message, "INTEGRITY_ERROR", EXIT_CODES.INTEGRITY);
10245
- this.name = "IntegrityError";
10246
- }
10247
- }
10248
-
10249
- class SelfUpdateError extends OCXError {
10250
- constructor(message) {
10251
- super(message, "UPDATE_ERROR", EXIT_CODES.GENERAL);
10252
- this.name = "SelfUpdateError";
10253
- }
10254
- }
10255
-
10256
- class OcxConfigError extends OCXError {
10257
- constructor(message) {
10258
- super(message, "CONFIG_ERROR", EXIT_CODES.CONFIG);
10259
- this.name = "OcxConfigError";
10260
- }
10261
- }
10262
-
10263
- class ProfileNotFoundError extends OCXError {
10264
- constructor(name) {
10265
- super(`Profile "${name}" not found`, "NOT_FOUND", EXIT_CODES.NOT_FOUND);
10266
- this.name = "ProfileNotFoundError";
10267
- }
10268
- }
10269
-
10270
- class ProfileExistsError extends OCXError {
10271
- constructor(name) {
10272
- super(`Profile "${name}" already exists`, "CONFLICT", EXIT_CODES.GENERAL);
10273
- this.name = "ProfileExistsError";
10274
- }
10275
- }
10276
-
10277
- class InvalidProfileNameError extends OCXError {
10278
- constructor(name, reason) {
10279
- super(`Invalid profile name "${name}": ${reason}`, "VALIDATION_ERROR", EXIT_CODES.GENERAL);
10280
- this.name = "InvalidProfileNameError";
10281
- }
10282
- }
10283
-
10284
- class ProfilesNotInitializedError extends OCXError {
10285
- constructor() {
10286
- super("Profiles not initialized. Run 'ocx profile add default' first.", "NOT_FOUND", EXIT_CODES.NOT_FOUND);
10287
- this.name = "ProfilesNotInitializedError";
10288
- }
10289
- }
10290
-
10291
10342
  // src/utils/paths.ts
10292
10343
  import { stat } from "fs/promises";
10293
10344
  import { homedir } from "os";
10294
- import { isAbsolute, join } from "path";
10345
+ import { isAbsolute as isAbsolute2, join } from "path";
10295
10346
  function getGlobalConfigPath() {
10296
10347
  const xdg = process.env.XDG_CONFIG_HOME;
10297
- const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".config");
10348
+ const base = xdg && isAbsolute2(xdg) ? xdg : join(homedir(), ".config");
10298
10349
  return join(base, "opencode");
10299
10350
  }
10300
10351
  async function globalDirectoryExists() {
@@ -10305,8 +10356,8 @@ async function globalDirectoryExists() {
10305
10356
  return false;
10306
10357
  }
10307
10358
  }
10308
- function resolveTargetPath(target, isGlobal) {
10309
- if (isGlobal && target.startsWith(".opencode/")) {
10359
+ function resolveTargetPath(target, isFlattened) {
10360
+ if (isFlattened && target.startsWith(".opencode/")) {
10310
10361
  return target.slice(".opencode/".length);
10311
10362
  }
10312
10363
  return target;
@@ -10887,7 +10938,7 @@ class ConfigResolver {
10887
10938
  // package.json
10888
10939
  var package_default = {
10889
10940
  name: "ocx",
10890
- version: "1.4.0",
10941
+ version: "1.4.1",
10891
10942
  description: "OCX CLI - ShadCN-style registry for OpenCode extensions. Install agents, plugins, skills, and MCP servers.",
10892
10943
  author: "kdcokenny",
10893
10944
  license: "MIT",
@@ -11162,8 +11213,19 @@ async function resolveDependencies(registries, componentNames) {
11162
11213
  // src/updaters/update-opencode-config.ts
11163
11214
  import { existsSync as existsSync4 } from "fs";
11164
11215
  import { mkdir as mkdir3 } from "fs/promises";
11216
+ import { homedir as homedir3 } from "os";
11165
11217
  import path4 from "path";
11166
11218
  var LOCAL_CONFIG_DIR3 = ".opencode";
11219
+ function isGlobalConfigPath(cwd) {
11220
+ const base = process.env.XDG_CONFIG_HOME || path4.join(homedir3(), ".config");
11221
+ const globalConfigDir = path4.resolve(base, "opencode");
11222
+ const resolvedCwd = path4.resolve(cwd);
11223
+ if (resolvedCwd === globalConfigDir) {
11224
+ return true;
11225
+ }
11226
+ const relative2 = path4.relative(globalConfigDir, resolvedCwd);
11227
+ return relative2 !== "" && !relative2.startsWith("..") && !path4.isAbsolute(relative2);
11228
+ }
11167
11229
  var JSONC_OPTIONS = {
11168
11230
  formattingOptions: {
11169
11231
  tabSize: 2,
@@ -11178,6 +11240,17 @@ var OPENCODE_CONFIG_TEMPLATE = `{
11178
11240
  }
11179
11241
  `;
11180
11242
  function findOpencodeConfig(cwd) {
11243
+ if (isGlobalConfigPath(cwd)) {
11244
+ const rootJsonc2 = path4.join(cwd, "opencode.jsonc");
11245
+ const rootJson2 = path4.join(cwd, "opencode.json");
11246
+ if (existsSync4(rootJsonc2)) {
11247
+ return { path: rootJsonc2, exists: true };
11248
+ }
11249
+ if (existsSync4(rootJson2)) {
11250
+ return { path: rootJson2, exists: true };
11251
+ }
11252
+ return { path: rootJsonc2, exists: false };
11253
+ }
11181
11254
  const dotOpencodeJsonc = path4.join(cwd, LOCAL_CONFIG_DIR3, "opencode.jsonc");
11182
11255
  const dotOpencodeJson = path4.join(cwd, LOCAL_CONFIG_DIR3, "opencode.json");
11183
11256
  if (existsSync4(dotOpencodeJsonc)) {
@@ -11267,7 +11340,7 @@ async function updateOpencodeJsonConfig(cwd, opencode) {
11267
11340
  } else {
11268
11341
  const config = { $schema: "https://opencode.ai/config.json" };
11269
11342
  content = JSON.stringify(config, null, "\t");
11270
- configPath = path4.join(cwd, LOCAL_CONFIG_DIR3, "opencode.jsonc");
11343
+ configPath = isGlobalConfigPath(cwd) ? path4.join(cwd, "opencode.jsonc") : path4.join(cwd, LOCAL_CONFIG_DIR3, "opencode.jsonc");
11271
11344
  await mkdir3(path4.dirname(configPath), { recursive: true });
11272
11345
  created = true;
11273
11346
  }
@@ -13182,8 +13255,9 @@ function registerAddCommand(program2) {
13182
13255
  const resolver = await ConfigResolver.create(options2.cwd ?? process.cwd(), {
13183
13256
  profile: options2.profile
13184
13257
  });
13258
+ const profileDir = getProfileDir(options2.profile);
13185
13259
  provider = {
13186
- cwd: resolver.getCwd(),
13260
+ cwd: profileDir,
13187
13261
  getRegistries: () => resolver.getRegistries(),
13188
13262
  getComponentPath: () => resolver.getComponentPath()
13189
13263
  };
@@ -13298,10 +13372,11 @@ async function handleNpmPlugins(inputs, options2, cwd) {
13298
13372
  }
13299
13373
  async function runRegistryAddCore(componentNames, options2, provider) {
13300
13374
  const cwd = provider.cwd;
13301
- const { path: lockPath } = findOcxLock(cwd);
13375
+ const isFlattened = !!options2.global || !!options2.profile;
13376
+ const { path: lockPath } = findOcxLock(cwd, { isFlattened });
13302
13377
  const registries = provider.getRegistries();
13303
13378
  let lock = { lockVersion: 1, installed: {} };
13304
- const existingLock = await readOcxLock(cwd);
13379
+ const existingLock = await readOcxLock(cwd, { isFlattened });
13305
13380
  if (existingLock) {
13306
13381
  lock = existingLock;
13307
13382
  }
@@ -13354,7 +13429,7 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13354
13429
  }
13355
13430
  const computedHash = await hashBundle(files);
13356
13431
  for (const file of component.files) {
13357
- const targetPath = join3(cwd, resolveTargetPath(file.target, !!options2.global));
13432
+ const targetPath = join3(cwd, resolveTargetPath(file.target, isFlattened));
13358
13433
  assertPathInside(targetPath, cwd);
13359
13434
  }
13360
13435
  const existingEntry = lock.installed[component.qualifiedName];
@@ -13363,7 +13438,7 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13363
13438
  throw new IntegrityError(component.qualifiedName, existingEntry.hash, computedHash);
13364
13439
  }
13365
13440
  for (const file of component.files) {
13366
- const resolvedTarget = resolveTargetPath(file.target, !!options2.global);
13441
+ const resolvedTarget = resolveTargetPath(file.target, isFlattened);
13367
13442
  const targetPath = join3(cwd, resolvedTarget);
13368
13443
  if (existsSync5(targetPath)) {
13369
13444
  const conflictingComponent = findComponentByFile(lock, resolvedTarget);
@@ -13387,7 +13462,7 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13387
13462
  const componentFile = component.files.find((f) => f.path === file.path);
13388
13463
  if (!componentFile)
13389
13464
  continue;
13390
- const resolvedTarget = resolveTargetPath(componentFile.target, !!options2.global);
13465
+ const resolvedTarget = resolveTargetPath(componentFile.target, isFlattened);
13391
13466
  const targetPath = join3(cwd, resolvedTarget);
13392
13467
  if (existsSync5(targetPath)) {
13393
13468
  const existingContent = await Bun.file(targetPath).text();
@@ -13415,7 +13490,7 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13415
13490
  const installResult = await installComponent(component, files, cwd, {
13416
13491
  force: options2.force,
13417
13492
  verbose: options2.verbose,
13418
- isGlobal: options2.global
13493
+ isFlattened
13419
13494
  });
13420
13495
  if (options2.verbose) {
13421
13496
  for (const f of installResult.skipped) {
@@ -13436,7 +13511,7 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13436
13511
  registry: component.registryName,
13437
13512
  version: index.version,
13438
13513
  hash: computedHash,
13439
- files: component.files.map((f) => resolveTargetPath(f.target, !!options2.global)),
13514
+ files: component.files.map((f) => resolveTargetPath(f.target, isFlattened)),
13440
13515
  installedAt: new Date().toISOString()
13441
13516
  };
13442
13517
  }
@@ -13453,12 +13528,12 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13453
13528
  }
13454
13529
  const hasNpmDeps = resolved.npmDependencies.length > 0;
13455
13530
  const hasNpmDevDeps = resolved.npmDevDependencies.length > 0;
13456
- const packageJsonPath = options2.global ? join3(cwd, "package.json") : join3(cwd, ".opencode/package.json");
13531
+ const packageJsonPath = options2.global || options2.profile ? join3(cwd, "package.json") : join3(cwd, ".opencode/package.json");
13457
13532
  if (hasNpmDeps || hasNpmDevDeps) {
13458
13533
  const npmSpin = options2.quiet ? null : createSpinner({ text: `Updating ${packageJsonPath}...` });
13459
13534
  npmSpin?.start();
13460
13535
  try {
13461
- await updateOpencodeDevDependencies(cwd, resolved.npmDependencies, resolved.npmDevDependencies, { isGlobal: options2.global });
13536
+ await updateOpencodeDevDependencies(cwd, resolved.npmDependencies, resolved.npmDevDependencies, { isFlattened });
13462
13537
  const totalDeps = resolved.npmDependencies.length + resolved.npmDevDependencies.length;
13463
13538
  npmSpin?.succeed(`Added ${totalDeps} dependencies to ${packageJsonPath}`);
13464
13539
  } catch (error) {
@@ -13494,7 +13569,7 @@ async function installComponent(component, files, cwd, options2) {
13494
13569
  const componentFile = component.files.find((f) => f.path === file.path);
13495
13570
  if (!componentFile)
13496
13571
  continue;
13497
- const resolvedTarget = resolveTargetPath(componentFile.target, !!options2.isGlobal);
13572
+ const resolvedTarget = resolveTargetPath(componentFile.target, !!options2.isFlattened);
13498
13573
  const targetPath = join3(cwd, resolvedTarget);
13499
13574
  const targetDir = dirname(targetPath);
13500
13575
  if (existsSync5(targetPath)) {
@@ -13594,7 +13669,7 @@ async function readOpencodePackageJson(opencodeDir) {
13594
13669
  return JSON.parse(content2);
13595
13670
  } catch (e3) {
13596
13671
  const message = e3 instanceof Error ? e3.message : String(e3);
13597
- throw new ConfigError(`Invalid .opencode/package.json: ${message}`);
13672
+ throw new ConfigError(`Invalid ${pkgPath}: ${message}`);
13598
13673
  }
13599
13674
  }
13600
13675
  async function ensureManifestFilesAreTracked(opencodeDir) {
@@ -13621,14 +13696,14 @@ async function updateOpencodeDevDependencies(cwd, npmDeps, npmDevDeps, options2
13621
13696
  const allDepSpecs = [...npmDeps, ...npmDevDeps];
13622
13697
  if (allDepSpecs.length === 0)
13623
13698
  return;
13624
- const packageDir = options2.isGlobal ? cwd : join3(cwd, ".opencode");
13699
+ const packageDir = options2.isFlattened ? cwd : join3(cwd, ".opencode");
13625
13700
  await mkdir4(packageDir, { recursive: true });
13626
13701
  const parsedDeps = allDepSpecs.map(parseNpmDependency);
13627
13702
  const existing = await readOpencodePackageJson(packageDir);
13628
13703
  const updated = mergeDevDependencies(existing, parsedDeps);
13629
13704
  await Bun.write(join3(packageDir, "package.json"), `${JSON.stringify(updated, null, 2)}
13630
13705
  `);
13631
- if (!options2.isGlobal) {
13706
+ if (!options2.isFlattened) {
13632
13707
  await ensureManifestFilesAreTracked(packageDir);
13633
13708
  }
13634
13709
  }
@@ -13689,7 +13764,7 @@ async function buildRegistry(options2) {
13689
13764
  const packumentPath = join4(componentsDir, `${component.name}.json`);
13690
13765
  await Bun.write(packumentPath, JSON.stringify(packument, null, 2));
13691
13766
  for (const rawFile of component.files) {
13692
- const file = normalizeFile(rawFile);
13767
+ const file = normalizeFile(rawFile, component.type);
13693
13768
  const sourceFilePath = join4(sourcePath, "files", file.path);
13694
13769
  const destFilePath = join4(componentsDir, component.name, file.path);
13695
13770
  const destFileDir = dirname2(destFilePath);
@@ -14851,11 +14926,40 @@ Diff for ${res.name}:`));
14851
14926
  }
14852
14927
 
14853
14928
  // src/commands/ghost/migrate.ts
14854
- import { copyFileSync, existsSync as existsSync7, lstatSync, readdirSync, renameSync, unlinkSync } from "fs";
14929
+ import {
14930
+ copyFileSync,
14931
+ cpSync,
14932
+ existsSync as existsSync7,
14933
+ lstatSync,
14934
+ mkdirSync,
14935
+ readdirSync,
14936
+ renameSync,
14937
+ rmdirSync,
14938
+ rmSync,
14939
+ unlinkSync
14940
+ } from "fs";
14855
14941
  import path6 from "path";
14856
14942
  var GHOST_CONFIG_FILE = "ghost.jsonc";
14857
14943
  var BACKUP_EXT = ".bak";
14858
14944
  var CURRENT_SYMLINK = "current";
14945
+ var FLATTEN_DIRS = ["plugin", "agent", "skill", "command"];
14946
+ function moveAtomically(source, destination, isDir) {
14947
+ try {
14948
+ renameSync(source, destination);
14949
+ } catch (err) {
14950
+ if (err instanceof Error && "code" in err && err.code === "EXDEV") {
14951
+ if (isDir) {
14952
+ cpSync(source, destination, { recursive: true });
14953
+ rmSync(source, { recursive: true, force: true });
14954
+ } else {
14955
+ copyFileSync(source, destination);
14956
+ unlinkSync(source);
14957
+ }
14958
+ } else {
14959
+ throw err;
14960
+ }
14961
+ }
14962
+ }
14859
14963
  function planMigration() {
14860
14964
  const profilesDir = getProfilesDir();
14861
14965
  const plan = {
@@ -14899,6 +15003,55 @@ function planMigration() {
14899
15003
  backup: backupPath
14900
15004
  });
14901
15005
  }
15006
+ for (const entry of entries) {
15007
+ if (!entry.isDirectory() || entry.name === CURRENT_SYMLINK)
15008
+ continue;
15009
+ const profileName = entry.name;
15010
+ const profileDir = path6.join(profilesDir, profileName);
15011
+ const dotOpencode = path6.join(profileDir, ".opencode");
15012
+ if (!existsSync7(dotOpencode))
15013
+ continue;
15014
+ for (const dir of FLATTEN_DIRS) {
15015
+ const source = path6.join(dotOpencode, dir);
15016
+ const destination = path6.join(profileDir, dir);
15017
+ if (!existsSync7(source))
15018
+ continue;
15019
+ try {
15020
+ const stat3 = lstatSync(source);
15021
+ if (!stat3.isDirectory()) {
15022
+ plan.skipped.push({
15023
+ profileName,
15024
+ reason: `.opencode/${dir} is not a directory`
15025
+ });
15026
+ continue;
15027
+ }
15028
+ } catch {
15029
+ continue;
15030
+ }
15031
+ if (existsSync7(destination)) {
15032
+ plan.skipped.push({
15033
+ profileName,
15034
+ reason: `${dir}/ exists in both .opencode/ and profile root`
15035
+ });
15036
+ continue;
15037
+ }
15038
+ const backupPath = source + BACKUP_EXT;
15039
+ if (existsSync7(backupPath)) {
15040
+ plan.skipped.push({
15041
+ profileName,
15042
+ reason: `backup already exists: .opencode/${dir}${BACKUP_EXT}`
15043
+ });
15044
+ continue;
15045
+ }
15046
+ plan.profiles.push({
15047
+ type: "move-dir",
15048
+ profileName,
15049
+ source,
15050
+ destination,
15051
+ backup: backupPath
15052
+ });
15053
+ }
15054
+ }
14902
15055
  const currentPath = path6.join(profilesDir, CURRENT_SYMLINK);
14903
15056
  if (existsSync7(currentPath)) {
14904
15057
  try {
@@ -14924,15 +15077,20 @@ function executeMigration(plan) {
14924
15077
  const completedRenames = [];
14925
15078
  try {
14926
15079
  for (const action of plan.profiles) {
14927
- copyFileSync(action.source, action.backup);
14928
- completedBackups.push(action.backup);
15080
+ if (action.type === "rename") {
15081
+ copyFileSync(action.source, action.backup);
15082
+ } else {
15083
+ cpSync(action.source, action.backup, { recursive: true });
15084
+ }
15085
+ completedBackups.push({ path: action.backup, isDir: action.type === "move-dir" });
14929
15086
  }
14930
15087
  for (const action of plan.profiles) {
14931
- renameSync(action.source, action.destination);
15088
+ moveAtomically(action.source, action.destination, action.type === "move-dir");
14932
15089
  completedRenames.push({
14933
15090
  source: action.source,
14934
15091
  destination: action.destination,
14935
- backup: action.backup
15092
+ backup: action.backup,
15093
+ isDir: action.type === "move-dir"
14936
15094
  });
14937
15095
  }
14938
15096
  if (plan.symlink) {
@@ -14945,32 +15103,61 @@ function executeMigration(plan) {
14945
15103
  }
14946
15104
  for (const backup of completedBackups) {
14947
15105
  try {
14948
- unlinkSync(backup);
15106
+ if (backup.isDir) {
15107
+ rmSync(backup.path, { recursive: true, force: true });
15108
+ } else {
15109
+ unlinkSync(backup.path);
15110
+ }
14949
15111
  } catch {}
14950
15112
  }
14951
15113
  } catch (error) {
14952
15114
  for (const rename2 of completedRenames) {
14953
15115
  try {
14954
15116
  if (existsSync7(rename2.destination)) {
14955
- renameSync(rename2.destination, rename2.source);
15117
+ if (rename2.isDir) {
15118
+ rmSync(rename2.destination, { recursive: true, force: true });
15119
+ } else {
15120
+ unlinkSync(rename2.destination);
15121
+ }
14956
15122
  }
14957
- } catch {
14958
- try {
14959
- if (existsSync7(rename2.backup)) {
15123
+ mkdirSync(path6.dirname(rename2.source), { recursive: true });
15124
+ if (existsSync7(rename2.backup)) {
15125
+ if (rename2.isDir) {
15126
+ cpSync(rename2.backup, rename2.source, { recursive: true });
15127
+ } else {
14960
15128
  copyFileSync(rename2.backup, rename2.source);
14961
15129
  }
14962
- } catch {}
14963
- }
15130
+ }
15131
+ } catch {}
14964
15132
  }
14965
15133
  for (const backup of completedBackups) {
14966
15134
  try {
14967
- if (existsSync7(backup)) {
14968
- unlinkSync(backup);
15135
+ if (existsSync7(backup.path)) {
15136
+ if (backup.isDir) {
15137
+ rmSync(backup.path, { recursive: true, force: true });
15138
+ } else {
15139
+ unlinkSync(backup.path);
15140
+ }
14969
15141
  }
14970
15142
  } catch {}
14971
15143
  }
14972
15144
  throw error;
14973
15145
  }
15146
+ try {
15147
+ const processedProfiles = new Set(plan.profiles.filter((a) => a.type === "move-dir").map((a) => a.profileName));
15148
+ for (const profileName of processedProfiles) {
15149
+ const profileDir = path6.join(getProfilesDir(), profileName);
15150
+ const dotOpencode = path6.join(profileDir, ".opencode");
15151
+ if (existsSync7(dotOpencode)) {
15152
+ const remaining = readdirSync(dotOpencode);
15153
+ if (remaining.length === 0) {
15154
+ try {
15155
+ rmdirSync(dotOpencode);
15156
+ } catch {}
15157
+ }
15158
+ }
15159
+ }
15160
+ } catch {}
14974
15161
  }
14975
15162
  function printPlan(plan, dryRun) {
14976
15163
  const prefix = dryRun ? "[DRY-RUN] " : "";
@@ -14983,7 +15170,12 @@ function printPlan(plan, dryRun) {
14983
15170
  if (plan.profiles.length > 0 || plan.skipped.length > 0) {
14984
15171
  console.log("Profiles:");
14985
15172
  for (const action of plan.profiles) {
14986
- console.log(` \u2713 ${action.profileName}: ${GHOST_CONFIG_FILE} \u2192 ${OCX_CONFIG_FILE}`);
15173
+ if (action.type === "rename") {
15174
+ console.log(` \u2713 ${action.profileName}: ${GHOST_CONFIG_FILE} \u2192 ${OCX_CONFIG_FILE}`);
15175
+ } else {
15176
+ const dirName = path6.basename(action.source);
15177
+ console.log(` \u2713 ${action.profileName}: .opencode/${dirName}/ \u2192 ${dirName}/`);
15178
+ }
14987
15179
  }
14988
15180
  for (const skipped of plan.skipped) {
14989
15181
  console.log(` \u26A0 ${skipped.profileName}: skipped (${skipped.reason})`);
@@ -15389,9 +15581,290 @@ async function runOpencode(pathArg, args, options2) {
15389
15581
  }
15390
15582
  }
15391
15583
 
15584
+ // src/commands/profile/install-from-registry.ts
15585
+ import { createHash as createHash2 } from "crypto";
15586
+ import { existsSync as existsSync9 } from "fs";
15587
+ import { mkdir as mkdir8, mkdtemp, rename as rename2, rm as rm3, writeFile as writeFile3 } from "fs/promises";
15588
+ import { dirname as dirname4, join as join8 } from "path";
15589
+ var PROFILE_FILE_TARGETS = new Set(["ocx.jsonc", "opencode.jsonc", "AGENTS.md"]);
15590
+ function isProfileFile(target) {
15591
+ return PROFILE_FILE_TARGETS.has(target);
15592
+ }
15593
+ function hashContent2(content2) {
15594
+ return createHash2("sha256").update(content2).digest("hex");
15595
+ }
15596
+ function hashBundle2(files) {
15597
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
15598
+ const manifestParts = [];
15599
+ for (const file of sorted) {
15600
+ const hash = hashContent2(file.content);
15601
+ manifestParts.push(`${file.path}:${hash}`);
15602
+ }
15603
+ return hashContent2(manifestParts.join(`
15604
+ `));
15605
+ }
15606
+ async function installProfileFromRegistry(options2) {
15607
+ const { namespace, component, profileName, force, registryUrl, registries, quiet } = options2;
15608
+ const parseResult = profileNameSchema.safeParse(profileName);
15609
+ if (!parseResult.success) {
15610
+ throw new ValidationError(`Invalid profile name: "${profileName}". ` + `Profile names must start with a letter and contain only alphanumeric characters, dots, underscores, or hyphens.`);
15611
+ }
15612
+ const profileDir = getProfileDir(profileName);
15613
+ const qualifiedName = `${namespace}/${component}`;
15614
+ const profileExists = existsSync9(profileDir);
15615
+ if (profileExists && !force) {
15616
+ throw new ConflictError(`Profile "${profileName}" already exists.
15617
+ Use --force to overwrite.`);
15618
+ }
15619
+ const fetchSpin = quiet ? null : createSpinner({ text: `Fetching ${qualifiedName}...` });
15620
+ fetchSpin?.start();
15621
+ let manifest;
15622
+ try {
15623
+ manifest = await fetchComponent(registryUrl, component);
15624
+ } catch (error) {
15625
+ fetchSpin?.fail(`Failed to fetch ${qualifiedName}`);
15626
+ if (error instanceof NotFoundError) {
15627
+ throw new NotFoundError(`Profile component "${qualifiedName}" not found in registry.
15628
+
15629
+ ` + `Check the component name and ensure the registry is configured.`);
15630
+ }
15631
+ throw error;
15632
+ }
15633
+ if (manifest.type !== "ocx:profile") {
15634
+ fetchSpin?.fail(`Invalid component type`);
15635
+ throw new ValidationError(`Component "${qualifiedName}" is type "${manifest.type}", not "ocx:profile".
15636
+
15637
+ ` + `Only profile components can be installed with 'ocx profile add --from'.`);
15638
+ }
15639
+ const normalized = normalizeComponentManifest(manifest);
15640
+ fetchSpin?.succeed(`Fetched ${qualifiedName}`);
15641
+ const filesSpin = quiet ? null : createSpinner({ text: "Downloading profile files..." });
15642
+ filesSpin?.start();
15643
+ const profileFiles = [];
15644
+ const dependencyFiles = [];
15645
+ for (const file of normalized.files) {
15646
+ const content2 = await fetchFileContent(registryUrl, component, file.path);
15647
+ const fileEntry = {
15648
+ path: file.path,
15649
+ target: file.target,
15650
+ content: Buffer.from(content2)
15651
+ };
15652
+ if (isProfileFile(file.target)) {
15653
+ profileFiles.push(fileEntry);
15654
+ } else {
15655
+ dependencyFiles.push(fileEntry);
15656
+ }
15657
+ }
15658
+ filesSpin?.succeed(`Downloaded ${normalized.files.length} files`);
15659
+ let resolvedDeps = null;
15660
+ const dependencyBundles = [];
15661
+ if (manifest.dependencies.length > 0) {
15662
+ const depsSpin = quiet ? null : createSpinner({ text: "Resolving dependencies..." });
15663
+ depsSpin?.start();
15664
+ try {
15665
+ const depRefs = manifest.dependencies.map((dep) => dep.includes("/") ? dep : `${namespace}/${dep}`);
15666
+ resolvedDeps = await resolveDependencies(registries, depRefs);
15667
+ for (const depComponent of resolvedDeps.components) {
15668
+ const files = [];
15669
+ for (const file of depComponent.files) {
15670
+ const content2 = await fetchFileContent(depComponent.baseUrl, depComponent.name, file.path);
15671
+ const resolvedTarget = resolveTargetPath(file.target, true);
15672
+ files.push({
15673
+ path: file.path,
15674
+ target: resolvedTarget,
15675
+ content: Buffer.from(content2)
15676
+ });
15677
+ }
15678
+ const registryIndex = await fetchRegistryIndex(depComponent.baseUrl);
15679
+ dependencyBundles.push({
15680
+ qualifiedName: depComponent.qualifiedName,
15681
+ registryName: depComponent.registryName,
15682
+ files,
15683
+ hash: hashBundle2(files),
15684
+ version: registryIndex.version
15685
+ });
15686
+ }
15687
+ depsSpin?.succeed(`Resolved ${resolvedDeps.components.length} dependencies`);
15688
+ } catch (error) {
15689
+ depsSpin?.fail("Failed to resolve dependencies");
15690
+ throw error;
15691
+ }
15692
+ }
15693
+ const profilesDir = getProfilesDir();
15694
+ await mkdir8(profilesDir, { recursive: true, mode: 448 });
15695
+ const stagingDir = await mkdtemp(join8(profilesDir, ".staging-"));
15696
+ const stagingOpencodeDir = join8(stagingDir, ".opencode");
15697
+ try {
15698
+ await mkdir8(stagingOpencodeDir, { recursive: true, mode: 448 });
15699
+ const writeSpin = quiet ? null : createSpinner({ text: "Writing profile files..." });
15700
+ writeSpin?.start();
15701
+ for (const file of profileFiles) {
15702
+ const targetPath = join8(stagingDir, file.target);
15703
+ const targetDir = dirname4(targetPath);
15704
+ if (!existsSync9(targetDir)) {
15705
+ await mkdir8(targetDir, { recursive: true });
15706
+ }
15707
+ await writeFile3(targetPath, file.content);
15708
+ }
15709
+ for (const file of dependencyFiles) {
15710
+ const target = file.target.startsWith(".opencode/") ? file.target.slice(".opencode/".length) : file.target;
15711
+ const targetPath = join8(stagingOpencodeDir, target);
15712
+ const targetDir = dirname4(targetPath);
15713
+ if (!existsSync9(targetDir)) {
15714
+ await mkdir8(targetDir, { recursive: true });
15715
+ }
15716
+ await writeFile3(targetPath, file.content);
15717
+ }
15718
+ writeSpin?.succeed(`Wrote ${profileFiles.length + dependencyFiles.length} profile files`);
15719
+ if (dependencyBundles.length > 0) {
15720
+ const depWriteSpin = quiet ? null : createSpinner({ text: "Writing dependency files..." });
15721
+ depWriteSpin?.start();
15722
+ let depFileCount = 0;
15723
+ for (const bundle of dependencyBundles) {
15724
+ for (const file of bundle.files) {
15725
+ const targetPath = join8(stagingOpencodeDir, file.target);
15726
+ const targetDir = dirname4(targetPath);
15727
+ if (!existsSync9(targetDir)) {
15728
+ await mkdir8(targetDir, { recursive: true });
15729
+ }
15730
+ await writeFile3(targetPath, file.content);
15731
+ depFileCount++;
15732
+ }
15733
+ }
15734
+ depWriteSpin?.succeed(`Wrote ${depFileCount} dependency files`);
15735
+ }
15736
+ const profileHash = hashBundle2(profileFiles.map((f) => ({ path: f.path, content: f.content })));
15737
+ const registryIndex = await fetchRegistryIndex(registryUrl);
15738
+ const lock = {
15739
+ version: 1,
15740
+ installedFrom: {
15741
+ registry: namespace,
15742
+ component,
15743
+ version: registryIndex.version,
15744
+ hash: profileHash,
15745
+ installedAt: new Date().toISOString()
15746
+ },
15747
+ installed: {}
15748
+ };
15749
+ for (const bundle of dependencyBundles) {
15750
+ lock.installed[bundle.qualifiedName] = {
15751
+ registry: bundle.registryName,
15752
+ version: bundle.version,
15753
+ hash: bundle.hash,
15754
+ files: bundle.files.map((f) => f.target),
15755
+ installedAt: new Date().toISOString()
15756
+ };
15757
+ }
15758
+ await writeFile3(join8(stagingDir, "ocx.lock"), JSON.stringify(lock, null, "\t"));
15759
+ const moveSpin = quiet ? null : createSpinner({ text: "Finalizing installation..." });
15760
+ moveSpin?.start();
15761
+ const profilesDir2 = dirname4(profileDir);
15762
+ if (!existsSync9(profilesDir2)) {
15763
+ await mkdir8(profilesDir2, { recursive: true, mode: 448 });
15764
+ }
15765
+ if (profileExists && force) {
15766
+ const backupDir = `${profileDir}.backup-${Date.now()}`;
15767
+ await rename2(profileDir, backupDir);
15768
+ try {
15769
+ await rename2(stagingDir, profileDir);
15770
+ } catch (err) {
15771
+ await rename2(backupDir, profileDir);
15772
+ throw err;
15773
+ }
15774
+ await rm3(backupDir, { recursive: true, force: true });
15775
+ } else {
15776
+ await rename2(stagingDir, profileDir);
15777
+ }
15778
+ moveSpin?.succeed("Installation complete");
15779
+ if (!quiet) {
15780
+ logger.info("");
15781
+ logger.success(`Installed profile "${profileName}" from ${qualifiedName}`);
15782
+ logger.info("");
15783
+ logger.info("Profile contents:");
15784
+ for (const file of profileFiles) {
15785
+ logger.info(` ${file.target}`);
15786
+ }
15787
+ if (dependencyBundles.length > 0) {
15788
+ logger.info("");
15789
+ logger.info("Dependencies:");
15790
+ for (const bundle of dependencyBundles) {
15791
+ logger.info(` ${bundle.qualifiedName}`);
15792
+ }
15793
+ }
15794
+ }
15795
+ } catch (error) {
15796
+ try {
15797
+ if (existsSync9(stagingDir)) {
15798
+ await rm3(stagingDir, { recursive: true });
15799
+ }
15800
+ } catch {}
15801
+ throw error;
15802
+ }
15803
+ }
15804
+
15392
15805
  // src/commands/profile/add.ts
15806
+ function parseFromOption(from) {
15807
+ if (!from?.trim()) {
15808
+ throw new ValidationError("--from value cannot be empty");
15809
+ }
15810
+ const trimmed = from.trim();
15811
+ if (trimmed.startsWith("./") || trimmed.startsWith("~/") || trimmed.startsWith("/")) {
15812
+ return { type: "local-path", path: trimmed };
15813
+ }
15814
+ const slashCount = (trimmed.match(/\//g) || []).length;
15815
+ if (slashCount === 1) {
15816
+ const [namespace, component] = trimmed.split("/").map((s) => s.trim());
15817
+ if (!namespace || !component) {
15818
+ throw new ValidationError(`Invalid registry reference: "${from}". Expected format: namespace/component`);
15819
+ }
15820
+ return { type: "registry", namespace, component };
15821
+ }
15822
+ return { type: "local-profile", name: trimmed };
15823
+ }
15824
+ async function readGlobalOcxConfig() {
15825
+ const configPath = getGlobalConfig();
15826
+ const file = Bun.file(configPath);
15827
+ if (!await file.exists()) {
15828
+ return null;
15829
+ }
15830
+ try {
15831
+ const content2 = await file.text();
15832
+ const json = parse2(content2, [], { allowTrailingComma: true });
15833
+ return profileOcxConfigSchema.parse(json);
15834
+ } catch (error) {
15835
+ if (error instanceof Error) {
15836
+ throw new ConfigError(`Failed to parse global config at "${configPath}": ${error.message}
15837
+
15838
+ ` + `Check the file for syntax errors or run: ocx config edit --global`);
15839
+ }
15840
+ throw error;
15841
+ }
15842
+ }
15843
+ async function requireGlobalRegistry(namespace) {
15844
+ const globalConfig = await readGlobalOcxConfig();
15845
+ if (!globalConfig) {
15846
+ throw new ConfigError(`Registry "${namespace}" is not configured globally.
15847
+
15848
+ ` + `Profile installation requires global registry configuration.
15849
+ ` + `Run: ocx registry add ${namespace} <url> --global`);
15850
+ }
15851
+ const registry = globalConfig.registries[namespace];
15852
+ if (!registry) {
15853
+ throw new ConfigError(`Registry "${namespace}" is not configured globally.
15854
+
15855
+ ` + `Profile installation requires global registry configuration.
15856
+ ` + `Run: ocx registry add ${namespace} <url> --global`);
15857
+ }
15858
+ return { config: globalConfig, registryUrl: registry.url };
15859
+ }
15393
15860
  function registerProfileAddCommand(parent) {
15394
- parent.command("add <name>").description("Create a new global profile").option("--from <profile>", "Clone settings from existing profile").action(async (name, options2) => {
15861
+ parent.command("add <name>").description("Create a new profile, clone from existing, or install from registry").option("--from <source>", "Clone from existing profile or install from registry (e.g., kdco/minimal)").option("-f, --force", "Overwrite existing profile").addHelpText("after", `
15862
+ Examples:
15863
+ $ ocx profile add work # Create empty profile
15864
+ $ ocx profile add work --from dev # Clone from existing profile
15865
+ $ ocx profile add work --from kdco/minimal # Install from registry
15866
+ $ ocx profile add work --from kdco/minimal --force # Overwrite existing
15867
+ `).action(async (name, options2) => {
15395
15868
  try {
15396
15869
  await runProfileAdd(name, options2);
15397
15870
  } catch (error) {
@@ -15401,15 +15874,61 @@ function registerProfileAddCommand(parent) {
15401
15874
  }
15402
15875
  async function runProfileAdd(name, options2) {
15403
15876
  const manager = await ProfileManager.requireInitialized();
15404
- if (options2.from) {
15405
- const source = await manager.get(options2.from);
15406
- await manager.add(name);
15407
- await atomicWrite(getProfileOcxConfig(name), source.ocx);
15408
- logger.success(`Created profile "${name}" (cloned from "${options2.from}")`);
15409
- } else {
15410
- await manager.add(name);
15411
- logger.success(`Created profile "${name}"`);
15877
+ const profileExists = await manager.exists(name);
15878
+ if (profileExists && !options2.force) {
15879
+ logger.error(`\u2717 Profile "${name}" already exists`);
15880
+ logger.error("");
15881
+ logger.error("Use --force to overwrite the existing profile.");
15882
+ throw new ProfileExistsError(name);
15883
+ }
15884
+ if (!options2.from) {
15885
+ await createEmptyProfile(manager, name, profileExists);
15886
+ return;
15412
15887
  }
15888
+ const fromInput = parseFromOption(options2.from);
15889
+ switch (fromInput.type) {
15890
+ case "local-profile":
15891
+ await cloneFromLocalProfile(manager, name, fromInput.name, profileExists);
15892
+ break;
15893
+ case "local-path":
15894
+ throw new ValidationError(`Local path installation is not yet implemented: "${fromInput.path}"
15895
+
15896
+ ` + `Currently supported sources:
15897
+ ` + ` - Existing profile: --from <profile-name>
15898
+ ` + ` - Registry: --from <namespace>/<component>`);
15899
+ case "registry": {
15900
+ const { config: globalConfig, registryUrl } = await requireGlobalRegistry(fromInput.namespace);
15901
+ const registries = {};
15902
+ for (const [ns, reg] of Object.entries(globalConfig.registries)) {
15903
+ registries[ns] = { url: reg.url };
15904
+ }
15905
+ await installProfileFromRegistry({
15906
+ namespace: fromInput.namespace,
15907
+ component: fromInput.component,
15908
+ profileName: name,
15909
+ force: options2.force,
15910
+ registryUrl,
15911
+ registries
15912
+ });
15913
+ break;
15914
+ }
15915
+ }
15916
+ }
15917
+ async function createEmptyProfile(manager, name, exists) {
15918
+ if (exists) {
15919
+ await manager.remove(name);
15920
+ }
15921
+ await manager.add(name);
15922
+ logger.success(`Created profile "${name}"`);
15923
+ }
15924
+ async function cloneFromLocalProfile(manager, name, sourceName, exists) {
15925
+ const source = await manager.get(sourceName);
15926
+ if (exists) {
15927
+ await manager.remove(name);
15928
+ }
15929
+ await manager.add(name);
15930
+ await atomicWrite(getProfileOcxConfig(name), source.ocx);
15931
+ logger.success(`Created profile "${name}" (cloned from "${sourceName}")`);
15413
15932
  }
15414
15933
 
15415
15934
  // src/commands/profile/config.ts
@@ -15566,31 +16085,56 @@ function runRegistryListCore(callbacks) {
15566
16085
  function registerRegistryCommand(program2) {
15567
16086
  const registry = program2.command("registry").description("Manage registries");
15568
16087
  const addCmd = registry.command("add").description("Add a registry").argument("<url>", "Registry URL").option("--name <name>", "Registry alias (defaults to hostname)").option("--version <version>", "Pin to specific version");
16088
+ addGlobalOption(addCmd);
15569
16089
  addCommonOptions(addCmd);
15570
- addCmd.action(async (url, options2) => {
16090
+ addCmd.action(async (url, options2, command) => {
15571
16091
  try {
15572
- const cwd = options2.cwd ?? process.cwd();
15573
- const { path: configPath } = findOcxConfig(cwd);
15574
- const config = await readOcxConfig(cwd);
15575
- if (!config) {
15576
- logger.error("No ocx.jsonc found. Run 'ocx init' first.");
16092
+ const cwdExplicitlyProvided = command.getOptionValueSource("cwd") === "cli";
16093
+ if (options2.global && cwdExplicitlyProvided) {
16094
+ logger.error("Cannot use --global with --cwd. They are mutually exclusive.");
15577
16095
  process.exit(1);
15578
16096
  }
16097
+ let configDir;
16098
+ let configPath;
16099
+ const config = await (async () => {
16100
+ if (options2.global) {
16101
+ configDir = getGlobalConfigPath();
16102
+ const found = findOcxConfig(configDir);
16103
+ configPath = found.path;
16104
+ const cfg = await readOcxConfig(configDir);
16105
+ if (!cfg) {
16106
+ logger.error("Global config not found. Run 'opencode' once to initialize.");
16107
+ process.exit(1);
16108
+ }
16109
+ return cfg;
16110
+ } else {
16111
+ configDir = options2.cwd ?? process.cwd();
16112
+ const found = findOcxConfig(configDir);
16113
+ configPath = found.path;
16114
+ const cfg = await readOcxConfig(configDir);
16115
+ if (!cfg) {
16116
+ logger.error("No ocx.jsonc found. Run 'ocx init' first.");
16117
+ process.exit(1);
16118
+ }
16119
+ return cfg;
16120
+ }
16121
+ })();
15579
16122
  const result = await runRegistryAddCore2(url, options2, {
15580
16123
  getRegistries: () => config.registries,
15581
16124
  isLocked: () => config.lockRegistries ?? false,
15582
16125
  setRegistry: async (name, regConfig) => {
15583
16126
  config.registries[name] = regConfig;
15584
- await writeOcxConfig(cwd, config, configPath);
16127
+ await writeOcxConfig(configDir, config, configPath);
15585
16128
  }
15586
16129
  });
15587
16130
  if (options2.json) {
15588
16131
  outputJson({ success: true, data: result });
15589
16132
  } else if (!options2.quiet) {
16133
+ const location = options2.global ? "global config" : "local config";
15590
16134
  if (result.updated) {
15591
- logger.success(`Updated registry: ${result.name} -> ${result.url}`);
16135
+ logger.success(`Updated registry in ${location}: ${result.name} -> ${result.url}`);
15592
16136
  } else {
15593
- logger.success(`Added registry: ${result.name} -> ${result.url}`);
16137
+ logger.success(`Added registry to ${location}: ${result.name} -> ${result.url}`);
15594
16138
  }
15595
16139
  }
15596
16140
  } catch (error) {
@@ -15598,43 +16142,90 @@ function registerRegistryCommand(program2) {
15598
16142
  }
15599
16143
  });
15600
16144
  const removeCmd = registry.command("remove").description("Remove a registry").argument("<name>", "Registry name");
16145
+ addGlobalOption(removeCmd);
15601
16146
  addCommonOptions(removeCmd);
15602
- removeCmd.action(async (name, options2) => {
16147
+ removeCmd.action(async (name, options2, command) => {
15603
16148
  try {
15604
- const cwd = options2.cwd ?? process.cwd();
15605
- const { path: configPath } = findOcxConfig(cwd);
15606
- const config = await readOcxConfig(cwd);
15607
- if (!config) {
15608
- logger.error("No ocx.jsonc found. Run 'ocx init' first.");
16149
+ const cwdExplicitlyProvided = command.getOptionValueSource("cwd") === "cli";
16150
+ if (options2.global && cwdExplicitlyProvided) {
16151
+ logger.error("Cannot use --global with --cwd. They are mutually exclusive.");
15609
16152
  process.exit(1);
15610
16153
  }
16154
+ let configDir;
16155
+ let configPath;
16156
+ const config = await (async () => {
16157
+ if (options2.global) {
16158
+ configDir = getGlobalConfigPath();
16159
+ const found = findOcxConfig(configDir);
16160
+ configPath = found.path;
16161
+ const cfg = await readOcxConfig(configDir);
16162
+ if (!cfg) {
16163
+ logger.error("Global config not found. Run 'opencode' once to initialize.");
16164
+ process.exit(1);
16165
+ }
16166
+ return cfg;
16167
+ } else {
16168
+ configDir = options2.cwd ?? process.cwd();
16169
+ const found = findOcxConfig(configDir);
16170
+ configPath = found.path;
16171
+ const cfg = await readOcxConfig(configDir);
16172
+ if (!cfg) {
16173
+ logger.error("No ocx.jsonc found. Run 'ocx init' first.");
16174
+ process.exit(1);
16175
+ }
16176
+ return cfg;
16177
+ }
16178
+ })();
15611
16179
  const result = await runRegistryRemoveCore(name, {
15612
16180
  getRegistries: () => config.registries,
15613
16181
  isLocked: () => config.lockRegistries ?? false,
15614
16182
  removeRegistry: async (regName) => {
15615
16183
  delete config.registries[regName];
15616
- await writeOcxConfig(cwd, config, configPath);
16184
+ await writeOcxConfig(configDir, config, configPath);
15617
16185
  }
15618
16186
  });
15619
16187
  if (options2.json) {
15620
16188
  outputJson({ success: true, data: result });
15621
16189
  } else if (!options2.quiet) {
15622
- logger.success(`Removed registry: ${result.removed}`);
16190
+ const location = options2.global ? "global config" : "local config";
16191
+ logger.success(`Removed registry from ${location}: ${result.removed}`);
15623
16192
  }
15624
16193
  } catch (error) {
15625
16194
  handleError(error, { json: options2.json });
15626
16195
  }
15627
16196
  });
15628
16197
  const listCmd = registry.command("list").description("List configured registries");
16198
+ addGlobalOption(listCmd);
15629
16199
  addCommonOptions(listCmd);
15630
- listCmd.action(async (options2) => {
16200
+ listCmd.action(async (options2, command) => {
15631
16201
  try {
15632
- const cwd = options2.cwd ?? process.cwd();
15633
- const config = await readOcxConfig(cwd);
15634
- if (!config) {
15635
- logger.warn("No ocx.jsonc found. Run 'ocx init' first.");
15636
- return;
16202
+ const cwdExplicitlyProvided = command.getOptionValueSource("cwd") === "cli";
16203
+ if (options2.global && cwdExplicitlyProvided) {
16204
+ logger.error("Cannot use --global with --cwd. They are mutually exclusive.");
16205
+ process.exit(1);
15637
16206
  }
16207
+ let configDir;
16208
+ const config = await (async () => {
16209
+ if (options2.global) {
16210
+ configDir = getGlobalConfigPath();
16211
+ const cfg = await readOcxConfig(configDir);
16212
+ if (!cfg) {
16213
+ logger.warn("Global config not found. Run 'opencode' once to initialize.");
16214
+ return null;
16215
+ }
16216
+ return cfg;
16217
+ } else {
16218
+ configDir = options2.cwd ?? process.cwd();
16219
+ const cfg = await readOcxConfig(configDir);
16220
+ if (!cfg) {
16221
+ logger.warn("No ocx.jsonc found. Run 'ocx init' first.");
16222
+ return null;
16223
+ }
16224
+ return cfg;
16225
+ }
16226
+ })();
16227
+ if (!config)
16228
+ return;
15638
16229
  const result = runRegistryListCore({
15639
16230
  getRegistries: () => config.registries,
15640
16231
  isLocked: () => config.lockRegistries ?? false
@@ -15645,7 +16236,7 @@ function registerRegistryCommand(program2) {
15645
16236
  if (result.registries.length === 0) {
15646
16237
  logger.info("No registries configured.");
15647
16238
  } else {
15648
- logger.info(`Configured registries${result.locked ? kleur_default.yellow(" (locked)") : ""}:`);
16239
+ logger.info(`Configured registries${options2.global ? " (global)" : ""}${result.locked ? kleur_default.yellow(" (locked)") : ""}:`);
15649
16240
  for (const reg of result.registries) {
15650
16241
  console.log(` ${kleur_default.cyan(reg.name)}: ${reg.url} ${kleur_default.dim(`(${reg.version})`)}`);
15651
16242
  }
@@ -15780,7 +16371,7 @@ async function runSearchCore(query, options2, provider) {
15780
16371
 
15781
16372
  // src/self-update/version-provider.ts
15782
16373
  class BuildTimeVersionProvider {
15783
- version = "1.4.0";
16374
+ version = "1.4.1";
15784
16375
  }
15785
16376
  var defaultVersionProvider = new BuildTimeVersionProvider;
15786
16377
 
@@ -15893,7 +16484,7 @@ function getExecutablePath() {
15893
16484
  }
15894
16485
 
15895
16486
  // src/self-update/download.ts
15896
- import { chmodSync, existsSync as existsSync9, renameSync as renameSync2, unlinkSync as unlinkSync2 } from "fs";
16487
+ import { chmodSync, existsSync as existsSync10, renameSync as renameSync2, unlinkSync as unlinkSync2 } from "fs";
15897
16488
  var GITHUB_REPO2 = "kdcokenny/ocx";
15898
16489
  var DEFAULT_DOWNLOAD_BASE_URL = `https://github.com/${GITHUB_REPO2}/releases/download`;
15899
16490
  var PLATFORM_MAP = {
@@ -15971,7 +16562,7 @@ async function downloadToTemp(version) {
15971
16562
  try {
15972
16563
  chmodSync(tempPath, 493);
15973
16564
  } catch (error) {
15974
- if (existsSync9(tempPath)) {
16565
+ if (existsSync10(tempPath)) {
15975
16566
  unlinkSync2(tempPath);
15976
16567
  }
15977
16568
  throw new SelfUpdateError(`Failed to set permissions: ${error instanceof Error ? error.message : String(error)}`);
@@ -15981,20 +16572,20 @@ async function downloadToTemp(version) {
15981
16572
  function atomicReplace(tempPath, execPath) {
15982
16573
  const backupPath = `${execPath}.backup`;
15983
16574
  try {
15984
- if (existsSync9(execPath)) {
16575
+ if (existsSync10(execPath)) {
15985
16576
  renameSync2(execPath, backupPath);
15986
16577
  }
15987
16578
  renameSync2(tempPath, execPath);
15988
- if (existsSync9(backupPath)) {
16579
+ if (existsSync10(backupPath)) {
15989
16580
  unlinkSync2(backupPath);
15990
16581
  }
15991
16582
  } catch (error) {
15992
- if (existsSync9(backupPath) && !existsSync9(execPath)) {
16583
+ if (existsSync10(backupPath) && !existsSync10(execPath)) {
15993
16584
  try {
15994
16585
  renameSync2(backupPath, execPath);
15995
16586
  } catch {}
15996
16587
  }
15997
- if (existsSync9(tempPath)) {
16588
+ if (existsSync10(tempPath)) {
15998
16589
  try {
15999
16590
  unlinkSync2(tempPath);
16000
16591
  } catch {}
@@ -16003,7 +16594,7 @@ function atomicReplace(tempPath, execPath) {
16003
16594
  }
16004
16595
  }
16005
16596
  function cleanupTempFile(tempPath) {
16006
- if (existsSync9(tempPath)) {
16597
+ if (existsSync10(tempPath)) {
16007
16598
  try {
16008
16599
  unlinkSync2(tempPath);
16009
16600
  } catch {}
@@ -16025,7 +16616,7 @@ function notifyUpdated(from, to) {
16025
16616
  }
16026
16617
 
16027
16618
  // src/self-update/verify.ts
16028
- import { createHash as createHash2 } from "crypto";
16619
+ import { createHash as createHash3 } from "crypto";
16029
16620
  var GITHUB_REPO3 = "kdcokenny/ocx";
16030
16621
  function parseSha256Sums(content2) {
16031
16622
  const checksums = new Map;
@@ -16038,8 +16629,8 @@ function parseSha256Sums(content2) {
16038
16629
  }
16039
16630
  return checksums;
16040
16631
  }
16041
- function hashContent2(content2) {
16042
- return createHash2("sha256").update(content2).digest("hex");
16632
+ function hashContent3(content2) {
16633
+ return createHash3("sha256").update(content2).digest("hex");
16043
16634
  }
16044
16635
  async function fetchChecksums(version) {
16045
16636
  const url = `https://github.com/${GITHUB_REPO3}/releases/download/v${version}/SHA256SUMS.txt`;
@@ -16053,7 +16644,7 @@ async function fetchChecksums(version) {
16053
16644
  async function verifyChecksum(filePath, expectedHash, filename) {
16054
16645
  const file = Bun.file(filePath);
16055
16646
  const content2 = await file.arrayBuffer();
16056
- const actualHash = hashContent2(Buffer.from(content2));
16647
+ const actualHash = hashContent3(Buffer.from(content2));
16057
16648
  if (actualHash !== expectedHash) {
16058
16649
  throw new IntegrityError(filename, expectedHash, actualHash);
16059
16650
  }
@@ -16178,10 +16769,10 @@ function registerSelfCommand(program2) {
16178
16769
  }
16179
16770
 
16180
16771
  // src/commands/update.ts
16181
- import { createHash as createHash3 } from "crypto";
16182
- import { existsSync as existsSync10 } from "fs";
16183
- import { mkdir as mkdir8, writeFile as writeFile3 } from "fs/promises";
16184
- import { dirname as dirname4, join as join8 } from "path";
16772
+ import { createHash as createHash4 } from "crypto";
16773
+ import { existsSync as existsSync11 } from "fs";
16774
+ import { mkdir as mkdir9, writeFile as writeFile4 } from "fs/promises";
16775
+ import { dirname as dirname5, join as join9 } from "path";
16185
16776
  function registerUpdateCommand(program2) {
16186
16777
  program2.command("update [components...]").description("Update installed components (use @version suffix to pin, e.g., kdco/agents@1.2.0)").option("--all", "Update all installed components").option("--registry <name>", "Update all components from a specific registry").option("--dry-run", "Preview changes without applying").option("--cwd <path>", "Working directory", process.cwd()).option("-q, --quiet", "Suppress output").option("-v, --verbose", "Verbose output").option("--json", "Output as JSON").action(async (components, options2) => {
16187
16778
  try {
@@ -16265,7 +16856,7 @@ Version cannot be empty. Use 'kdco/agents@1.2.0' or omit the version for latest.
16265
16856
  const content2 = await fetchFileContent(regConfig.url, componentName, file.path);
16266
16857
  files.push({ path: file.path, content: Buffer.from(content2) });
16267
16858
  }
16268
- const newHash = await hashBundle2(files);
16859
+ const newHash = await hashBundle3(files);
16269
16860
  if (newHash === lockEntry.hash) {
16270
16861
  results.push({
16271
16862
  qualifiedName,
@@ -16319,12 +16910,12 @@ Version cannot be empty. Use 'kdco/agents@1.2.0' or omit the version for latest.
16319
16910
  const fileObj = update.component.files.find((f) => f.path === file.path);
16320
16911
  if (!fileObj)
16321
16912
  continue;
16322
- const targetPath = join8(provider.cwd, fileObj.target);
16323
- const targetDir = dirname4(targetPath);
16324
- if (!existsSync10(targetDir)) {
16325
- await mkdir8(targetDir, { recursive: true });
16913
+ const targetPath = join9(provider.cwd, fileObj.target);
16914
+ const targetDir = dirname5(targetPath);
16915
+ if (!existsSync11(targetDir)) {
16916
+ await mkdir9(targetDir, { recursive: true });
16326
16917
  }
16327
- await writeFile3(targetPath, file.content);
16918
+ await writeFile4(targetPath, file.content);
16328
16919
  if (options2.verbose) {
16329
16920
  logger.info(` \u2713 Updated ${fileObj.target}`);
16330
16921
  }
@@ -16443,17 +17034,17 @@ function outputDryRun(results, options2) {
16443
17034
  }
16444
17035
  }
16445
17036
  }
16446
- async function hashContent3(content2) {
16447
- return createHash3("sha256").update(content2).digest("hex");
17037
+ async function hashContent4(content2) {
17038
+ return createHash4("sha256").update(content2).digest("hex");
16448
17039
  }
16449
- async function hashBundle2(files) {
17040
+ async function hashBundle3(files) {
16450
17041
  const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
16451
17042
  const manifestParts = [];
16452
17043
  for (const file of sorted) {
16453
- const hash = await hashContent3(file.content);
17044
+ const hash = await hashContent4(file.content);
16454
17045
  manifestParts.push(`${file.path}:${hash}`);
16455
17046
  }
16456
- return hashContent3(manifestParts.join(`
17047
+ return hashContent4(manifestParts.join(`
16457
17048
  `));
16458
17049
  }
16459
17050
 
@@ -16485,7 +17076,7 @@ function registerUpdateCheckHook(program2) {
16485
17076
  });
16486
17077
  }
16487
17078
  // src/index.ts
16488
- var version = "1.4.0";
17079
+ var version = "1.4.1";
16489
17080
  async function main2() {
16490
17081
  const program2 = new Command().name("ocx").description("OpenCode Extensions - Install agents, skills, plugins, and commands").version(version);
16491
17082
  registerInitCommand(program2);
@@ -16517,4 +17108,4 @@ export {
16517
17108
  buildRegistry
16518
17109
  };
16519
17110
 
16520
- //# debugId=5FADDAC480C59C7864756E2164756E21
17111
+ //# debugId=4E10E39074E11B3A64756E2164756E21