ocx 1.4.0 → 1.4.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.
package/dist/index.js CHANGED
@@ -9814,6 +9814,142 @@ 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
+ CONFLICT: 6
9829
+ };
9830
+
9831
+ class OCXError extends Error {
9832
+ code;
9833
+ exitCode;
9834
+ constructor(message, code, exitCode = EXIT_CODES.GENERAL) {
9835
+ super(message);
9836
+ this.code = code;
9837
+ this.exitCode = exitCode;
9838
+ this.name = "OCXError";
9839
+ }
9840
+ }
9841
+
9842
+ class NotFoundError extends OCXError {
9843
+ constructor(message) {
9844
+ super(message, "NOT_FOUND", EXIT_CODES.NOT_FOUND);
9845
+ this.name = "NotFoundError";
9846
+ }
9847
+ }
9848
+
9849
+ class NetworkError extends OCXError {
9850
+ constructor(message) {
9851
+ super(message, "NETWORK_ERROR", EXIT_CODES.NETWORK);
9852
+ this.name = "NetworkError";
9853
+ }
9854
+ }
9855
+
9856
+ class ConfigError extends OCXError {
9857
+ constructor(message) {
9858
+ super(message, "CONFIG_ERROR", EXIT_CODES.CONFIG);
9859
+ this.name = "ConfigError";
9860
+ }
9861
+ }
9862
+
9863
+ class ValidationError extends OCXError {
9864
+ constructor(message) {
9865
+ super(message, "VALIDATION_ERROR", EXIT_CODES.GENERAL);
9866
+ this.name = "ValidationError";
9867
+ }
9868
+ }
9869
+
9870
+ class ConflictError extends OCXError {
9871
+ constructor(message) {
9872
+ super(message, "CONFLICT", EXIT_CODES.CONFLICT);
9873
+ this.name = "ConflictError";
9874
+ }
9875
+ }
9876
+
9877
+ class IntegrityError extends OCXError {
9878
+ constructor(component, expected, found) {
9879
+ const message = `Integrity verification failed for "${component}"
9880
+ ` + ` Expected: ${expected}
9881
+ ` + ` Found: ${found}
9882
+
9883
+ ` + `The registry content has changed since this component was locked.
9884
+ ` + `Use 'ocx update ${component}' to intentionally update this component.`;
9885
+ super(message, "INTEGRITY_ERROR", EXIT_CODES.INTEGRITY);
9886
+ this.name = "IntegrityError";
9887
+ }
9888
+ }
9889
+
9890
+ class SelfUpdateError extends OCXError {
9891
+ constructor(message) {
9892
+ super(message, "UPDATE_ERROR", EXIT_CODES.GENERAL);
9893
+ this.name = "SelfUpdateError";
9894
+ }
9895
+ }
9896
+
9897
+ class OcxConfigError extends OCXError {
9898
+ constructor(message) {
9899
+ super(message, "CONFIG_ERROR", EXIT_CODES.CONFIG);
9900
+ this.name = "OcxConfigError";
9901
+ }
9902
+ }
9903
+
9904
+ class ProfileNotFoundError extends OCXError {
9905
+ constructor(name) {
9906
+ super(`Profile "${name}" not found`, "NOT_FOUND", EXIT_CODES.NOT_FOUND);
9907
+ this.name = "ProfileNotFoundError";
9908
+ }
9909
+ }
9910
+
9911
+ class ProfileExistsError extends OCXError {
9912
+ constructor(name) {
9913
+ super(`Profile "${name}" already exists. Use --force to overwrite.`, "CONFLICT", EXIT_CODES.CONFLICT);
9914
+ this.name = "ProfileExistsError";
9915
+ }
9916
+ }
9917
+
9918
+ class RegistryExistsError extends OCXError {
9919
+ registryName;
9920
+ existingUrl;
9921
+ newUrl;
9922
+ targetLabel;
9923
+ constructor(registryName, existingUrl, newUrl, targetLabel) {
9924
+ const target = targetLabel ? ` in ${targetLabel}` : "";
9925
+ const message = `Registry "${registryName}" already exists${target}.
9926
+ ` + ` Current: ${existingUrl}
9927
+ ` + ` New: ${newUrl}
9928
+
9929
+ ` + `Use --force to overwrite.`;
9930
+ super(message, "CONFLICT", EXIT_CODES.CONFLICT);
9931
+ this.registryName = registryName;
9932
+ this.existingUrl = existingUrl;
9933
+ this.newUrl = newUrl;
9934
+ this.targetLabel = targetLabel;
9935
+ this.name = "RegistryExistsError";
9936
+ }
9937
+ }
9938
+
9939
+ class InvalidProfileNameError extends OCXError {
9940
+ constructor(name, reason) {
9941
+ super(`Invalid profile name "${name}": ${reason}`, "VALIDATION_ERROR", EXIT_CODES.GENERAL);
9942
+ this.name = "InvalidProfileNameError";
9943
+ }
9944
+ }
9945
+
9946
+ class ProfilesNotInitializedError extends OCXError {
9947
+ constructor() {
9948
+ super("Profiles not initialized. Run 'ocx init --global' first.", "NOT_FOUND", EXIT_CODES.NOT_FOUND);
9949
+ this.name = "ProfilesNotInitializedError";
9950
+ }
9951
+ }
9952
+
9817
9953
  // src/schemas/registry.ts
9818
9954
  var npmSpecifierSchema = exports_external.string().refine((val) => val.startsWith("npm:"), {
9819
9955
  message: 'npm specifier must start with "npm:" prefix'
@@ -9860,8 +9996,10 @@ var componentTypeSchema = exports_external.enum([
9860
9996
  "ocx:plugin",
9861
9997
  "ocx:command",
9862
9998
  "ocx:tool",
9863
- "ocx:bundle"
9999
+ "ocx:bundle",
10000
+ "ocx:profile"
9864
10001
  ]);
10002
+ var profileTargetPathSchema = exports_external.enum(["ocx.jsonc", "opencode.jsonc", "AGENTS.md"]);
9865
10003
  var targetPathSchema = exports_external.string().refine((path) => path.startsWith(".opencode/"), {
9866
10004
  message: 'Target path must start with ".opencode/"'
9867
10005
  }).refine((path) => {
@@ -9901,7 +10039,7 @@ var mcpServerObjectSchema = exports_external.object({
9901
10039
  var mcpServerRefSchema = exports_external.union([exports_external.string(), mcpServerObjectSchema]);
9902
10040
  var componentFileObjectSchema = exports_external.object({
9903
10041
  path: exports_external.string().min(1, "File path cannot be empty"),
9904
- target: targetPathSchema
10042
+ target: exports_external.string().min(1, "Target path cannot be empty")
9905
10043
  });
9906
10044
  var componentFileSchema = exports_external.union([
9907
10045
  exports_external.string().min(1, "File path cannot be empty"),
@@ -9993,16 +10131,56 @@ var componentManifestSchema = exports_external.object({
9993
10131
  npmDevDependencies: exports_external.array(exports_external.string()).optional(),
9994
10132
  opencode: opencodeConfigSchema.optional()
9995
10133
  });
10134
+ function validateSafePath(filePath) {
10135
+ if (isAbsolute(filePath)) {
10136
+ throw new ValidationError(`Invalid path: "${filePath}" - absolute paths not allowed`);
10137
+ }
10138
+ if (filePath.startsWith("~")) {
10139
+ throw new ValidationError(`Invalid path: "${filePath}" - home directory paths not allowed`);
10140
+ }
10141
+ const normalized = normalize(filePath);
10142
+ if (normalized.startsWith("..")) {
10143
+ throw new ValidationError(`Invalid path: "${filePath}" - path traversal not allowed`);
10144
+ }
10145
+ }
9996
10146
  function inferTargetPath(sourcePath) {
9997
10147
  return `.opencode/${sourcePath}`;
9998
10148
  }
9999
- function normalizeFile(file) {
10149
+ function validateFileTarget(target, componentType) {
10150
+ const isProfile = componentType === "ocx:profile";
10151
+ if (isProfile) {
10152
+ const isProfileFile = profileTargetPathSchema.safeParse(target).success;
10153
+ const isOpencodeTarget = target.startsWith(".opencode/");
10154
+ if (!isProfileFile && !isOpencodeTarget) {
10155
+ throw new ValidationError(`Invalid profile target: "${target}". ` + `Must be a profile file (ocx.jsonc, opencode.jsonc, AGENTS.md) or start with ".opencode/"`);
10156
+ }
10157
+ if (isOpencodeTarget) {
10158
+ const parseResult = targetPathSchema.safeParse(target);
10159
+ if (!parseResult.success) {
10160
+ throw new ValidationError(`Invalid embedded target: "${target}". ${parseResult.error.errors[0]?.message}`);
10161
+ }
10162
+ }
10163
+ } else {
10164
+ const parseResult = targetPathSchema.safeParse(target);
10165
+ if (!parseResult.success) {
10166
+ throw new ValidationError(`Invalid target: "${target}". ${parseResult.error.errors[0]?.message}`);
10167
+ }
10168
+ }
10169
+ }
10170
+ function normalizeFile(file, componentType) {
10171
+ const isProfile = componentType === "ocx:profile";
10000
10172
  if (typeof file === "string") {
10173
+ validateSafePath(file);
10174
+ const target = isProfile ? file : inferTargetPath(file);
10175
+ validateFileTarget(target, componentType);
10001
10176
  return {
10002
10177
  path: file,
10003
- target: inferTargetPath(file)
10178
+ target
10004
10179
  };
10005
10180
  }
10181
+ validateSafePath(file.path);
10182
+ validateSafePath(file.target);
10183
+ validateFileTarget(file.target, componentType);
10006
10184
  return file;
10007
10185
  }
10008
10186
  function normalizeMcpServer(server) {
@@ -10028,7 +10206,7 @@ function normalizeComponentManifest(manifest) {
10028
10206
  }
10029
10207
  return {
10030
10208
  ...manifest,
10031
- files: manifest.files.map(normalizeFile),
10209
+ files: manifest.files.map((file) => normalizeFile(file, manifest.type)),
10032
10210
  opencode: normalizedOpencode
10033
10211
  };
10034
10212
  }
@@ -10128,9 +10306,15 @@ function findOcxConfig(cwd) {
10128
10306
  }
10129
10307
  return { path: dotOpencodePath, exists: false };
10130
10308
  }
10131
- function findOcxLock(cwd) {
10309
+ function findOcxLock(cwd, options) {
10132
10310
  const dotOpencodePath = path.join(cwd, LOCAL_CONFIG_DIR, LOCK_FILE);
10133
10311
  const rootPath = path.join(cwd, LOCK_FILE);
10312
+ if (options?.isFlattened) {
10313
+ if (existsSync(rootPath)) {
10314
+ return { path: rootPath, exists: true };
10315
+ }
10316
+ return { path: rootPath, exists: false };
10317
+ }
10134
10318
  if (existsSync(dotOpencodePath)) {
10135
10319
  return { path: dotOpencodePath, exists: true };
10136
10320
  }
@@ -10160,8 +10344,8 @@ async function writeOcxConfig(cwd, config, existingPath) {
10160
10344
  const content = JSON.stringify(config, null, 2);
10161
10345
  await Bun.write(configPath, content);
10162
10346
  }
10163
- async function readOcxLock(cwd) {
10164
- const { path: lockPath, exists } = findOcxLock(cwd);
10347
+ async function readOcxLock(cwd, options) {
10348
+ const { path: lockPath, exists } = findOcxLock(cwd, options);
10165
10349
  if (!exists) {
10166
10350
  return null;
10167
10351
  }
@@ -10177,124 +10361,13 @@ async function writeOcxLock(cwd, lock, existingPath) {
10177
10361
  await Bun.write(lockPath, content);
10178
10362
  }
10179
10363
 
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
10364
  // src/utils/paths.ts
10292
10365
  import { stat } from "fs/promises";
10293
10366
  import { homedir } from "os";
10294
- import { isAbsolute, join } from "path";
10367
+ import { isAbsolute as isAbsolute2, join } from "path";
10295
10368
  function getGlobalConfigPath() {
10296
10369
  const xdg = process.env.XDG_CONFIG_HOME;
10297
- const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".config");
10370
+ const base = xdg && isAbsolute2(xdg) ? xdg : join(homedir(), ".config");
10298
10371
  return join(base, "opencode");
10299
10372
  }
10300
10373
  async function globalDirectoryExists() {
@@ -10305,8 +10378,8 @@ async function globalDirectoryExists() {
10305
10378
  return false;
10306
10379
  }
10307
10380
  }
10308
- function resolveTargetPath(target, isGlobal) {
10309
- if (isGlobal && target.startsWith(".opencode/")) {
10381
+ function resolveTargetPath(target, isFlattened) {
10382
+ if (isFlattened && target.startsWith(".opencode/")) {
10310
10383
  return target.slice(".opencode/".length);
10311
10384
  }
10312
10385
  return target;
@@ -10348,7 +10421,7 @@ class GlobalConfigProvider {
10348
10421
  static async requireInitialized() {
10349
10422
  const basePath = getGlobalConfigPath();
10350
10423
  if (!await globalDirectoryExists()) {
10351
- throw new ConfigError("Global config not found. Run 'opencode' once to initialize, then retry.");
10424
+ throw new ConfigError("Global config not found. Run 'ocx init --global' first.");
10352
10425
  }
10353
10426
  const config = await readOcxConfig(basePath);
10354
10427
  return new GlobalConfigProvider(basePath, config);
@@ -10575,7 +10648,25 @@ class ProfileManager {
10575
10648
  const dir = getProfileDir(name);
10576
10649
  await mkdir2(dir, { recursive: true, mode: 448 });
10577
10650
  const ocxPath = getProfileOcxConfig(name);
10578
- await atomicWrite(ocxPath, DEFAULT_OCX_CONFIG);
10651
+ const ocxFile = Bun.file(ocxPath);
10652
+ if (!await ocxFile.exists()) {
10653
+ await atomicWrite(ocxPath, DEFAULT_OCX_CONFIG);
10654
+ }
10655
+ const opencodePath = getProfileOpencodeConfig(name);
10656
+ const opencodeFile = Bun.file(opencodePath);
10657
+ if (!await opencodeFile.exists()) {
10658
+ await atomicWrite(opencodePath, {});
10659
+ }
10660
+ const agentsPath = getProfileAgents(name);
10661
+ const agentsFile = Bun.file(agentsPath);
10662
+ if (!await agentsFile.exists()) {
10663
+ const agentsContent = `# Profile Instructions
10664
+
10665
+ <!-- Add your custom instructions for this profile here -->
10666
+ <!-- These will be included when running \`ocx opencode -p ${name}\` -->
10667
+ `;
10668
+ await Bun.write(agentsPath, agentsContent, { mode: 384 });
10669
+ }
10579
10670
  }
10580
10671
  async remove(name) {
10581
10672
  if (!await this.exists(name)) {
@@ -10714,11 +10805,13 @@ class ConfigResolver {
10714
10805
  }
10715
10806
  }
10716
10807
  const shouldLoadLocal = this.shouldLoadLocalConfig();
10717
- if (shouldLoadLocal && this.localConfigDir) {
10808
+ if (!this.profile && shouldLoadLocal && this.localConfigDir) {
10718
10809
  const localOcxConfig = this.loadLocalOcxConfig();
10719
10810
  if (localOcxConfig) {
10720
- registries = { ...registries, ...localOcxConfig.registries };
10811
+ registries = localOcxConfig.registries;
10721
10812
  }
10813
+ }
10814
+ if (shouldLoadLocal && this.localConfigDir) {
10722
10815
  const localOpencodeConfig = this.loadLocalOpencodeConfig();
10723
10816
  if (localOpencodeConfig) {
10724
10817
  opencode = this.deepMerge(opencode, localOpencodeConfig);
@@ -10759,7 +10852,7 @@ class ConfigResolver {
10759
10852
  }
10760
10853
  }
10761
10854
  const shouldLoadLocal = this.shouldLoadLocalConfig();
10762
- if (shouldLoadLocal && this.localConfigDir) {
10855
+ if (!this.profile && shouldLoadLocal && this.localConfigDir) {
10763
10856
  const localOcxConfig = this.loadLocalOcxConfig();
10764
10857
  if (localOcxConfig) {
10765
10858
  const localOcxPath = join2(this.localConfigDir, OCX_CONFIG_FILE);
@@ -10768,6 +10861,8 @@ class ConfigResolver {
10768
10861
  origins.set(`registries.${key}`, { path: localOcxPath, source: "local-config" });
10769
10862
  }
10770
10863
  }
10864
+ }
10865
+ if (shouldLoadLocal && this.localConfigDir) {
10771
10866
  const localOpencodeConfig = this.loadLocalOpencodeConfig();
10772
10867
  if (localOpencodeConfig) {
10773
10868
  opencode = this.deepMerge(opencode, localOpencodeConfig);
@@ -10887,7 +10982,7 @@ class ConfigResolver {
10887
10982
  // package.json
10888
10983
  var package_default = {
10889
10984
  name: "ocx",
10890
- version: "1.4.0",
10985
+ version: "1.4.2",
10891
10986
  description: "OCX CLI - ShadCN-style registry for OpenCode extensions. Install agents, plugins, skills, and MCP servers.",
10892
10987
  author: "kdcokenny",
10893
10988
  license: "MIT",
@@ -11162,8 +11257,19 @@ async function resolveDependencies(registries, componentNames) {
11162
11257
  // src/updaters/update-opencode-config.ts
11163
11258
  import { existsSync as existsSync4 } from "fs";
11164
11259
  import { mkdir as mkdir3 } from "fs/promises";
11260
+ import { homedir as homedir3 } from "os";
11165
11261
  import path4 from "path";
11166
11262
  var LOCAL_CONFIG_DIR3 = ".opencode";
11263
+ function isGlobalConfigPath(cwd) {
11264
+ const base = process.env.XDG_CONFIG_HOME || path4.join(homedir3(), ".config");
11265
+ const globalConfigDir = path4.resolve(base, "opencode");
11266
+ const resolvedCwd = path4.resolve(cwd);
11267
+ if (resolvedCwd === globalConfigDir) {
11268
+ return true;
11269
+ }
11270
+ const relative2 = path4.relative(globalConfigDir, resolvedCwd);
11271
+ return relative2 !== "" && !relative2.startsWith("..") && !path4.isAbsolute(relative2);
11272
+ }
11167
11273
  var JSONC_OPTIONS = {
11168
11274
  formattingOptions: {
11169
11275
  tabSize: 2,
@@ -11178,6 +11284,17 @@ var OPENCODE_CONFIG_TEMPLATE = `{
11178
11284
  }
11179
11285
  `;
11180
11286
  function findOpencodeConfig(cwd) {
11287
+ if (isGlobalConfigPath(cwd)) {
11288
+ const rootJsonc2 = path4.join(cwd, "opencode.jsonc");
11289
+ const rootJson2 = path4.join(cwd, "opencode.json");
11290
+ if (existsSync4(rootJsonc2)) {
11291
+ return { path: rootJsonc2, exists: true };
11292
+ }
11293
+ if (existsSync4(rootJson2)) {
11294
+ return { path: rootJson2, exists: true };
11295
+ }
11296
+ return { path: rootJsonc2, exists: false };
11297
+ }
11181
11298
  const dotOpencodeJsonc = path4.join(cwd, LOCAL_CONFIG_DIR3, "opencode.jsonc");
11182
11299
  const dotOpencodeJson = path4.join(cwd, LOCAL_CONFIG_DIR3, "opencode.json");
11183
11300
  if (existsSync4(dotOpencodeJsonc)) {
@@ -11267,7 +11384,7 @@ async function updateOpencodeJsonConfig(cwd, opencode) {
11267
11384
  } else {
11268
11385
  const config = { $schema: "https://opencode.ai/config.json" };
11269
11386
  content = JSON.stringify(config, null, "\t");
11270
- configPath = path4.join(cwd, LOCAL_CONFIG_DIR3, "opencode.jsonc");
11387
+ configPath = isGlobalConfigPath(cwd) ? path4.join(cwd, "opencode.jsonc") : path4.join(cwd, LOCAL_CONFIG_DIR3, "opencode.jsonc");
11271
11388
  await mkdir3(path4.dirname(configPath), { recursive: true });
11272
11389
  created = true;
11273
11390
  }
@@ -11559,6 +11676,25 @@ function wrapAction(action) {
11559
11676
  };
11560
11677
  }
11561
11678
  function formatErrorAsJson(error) {
11679
+ if (error instanceof RegistryExistsError) {
11680
+ return {
11681
+ success: false,
11682
+ error: {
11683
+ code: error.code,
11684
+ message: error.message,
11685
+ details: {
11686
+ registryName: error.registryName,
11687
+ existingUrl: error.existingUrl,
11688
+ newUrl: error.newUrl,
11689
+ ...error.targetLabel && { targetLabel: error.targetLabel }
11690
+ }
11691
+ },
11692
+ exitCode: error.exitCode,
11693
+ meta: {
11694
+ timestamp: new Date().toISOString()
11695
+ }
11696
+ };
11697
+ }
11562
11698
  if (error instanceof OCXError) {
11563
11699
  return {
11564
11700
  success: false,
@@ -11622,6 +11758,7 @@ var sharedOptions = {
11622
11758
  cwd: () => new Option("--cwd <path>", "Working directory").default(process.cwd()),
11623
11759
  quiet: () => new Option("-q, --quiet", "Suppress output"),
11624
11760
  json: () => new Option("--json", "Output as JSON"),
11761
+ profile: () => new Option("-p, --profile <name>", "Target a specific profile's config"),
11625
11762
  force: () => new Option("-f, --force", "Skip confirmation prompts"),
11626
11763
  verbose: () => new Option("-v, --verbose", "Verbose output"),
11627
11764
  global: new Option("-g, --global", "Install to global OpenCode config (~/.config/opencode)")
@@ -11638,6 +11775,20 @@ function addVerboseOption(cmd) {
11638
11775
  function addGlobalOption(cmd) {
11639
11776
  return cmd.addOption(sharedOptions.global);
11640
11777
  }
11778
+ function addProfileOption(cmd) {
11779
+ return cmd.addOption(sharedOptions.profile());
11780
+ }
11781
+ function validateProfileName(name) {
11782
+ if (!name || name.length === 0) {
11783
+ throw new InvalidProfileNameError(name, "cannot be empty");
11784
+ }
11785
+ if (name.length > 32) {
11786
+ throw new InvalidProfileNameError(name, "must be 32 characters or less");
11787
+ }
11788
+ if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) {
11789
+ throw new InvalidProfileNameError(name, "must start with a letter and contain only alphanumeric characters, dots, underscores, or hyphens");
11790
+ }
11791
+ }
11641
11792
  // ../../node_modules/.bun/ora@8.2.0/node_modules/ora/index.js
11642
11793
  import process9 from "process";
11643
11794
 
@@ -13182,8 +13333,9 @@ function registerAddCommand(program2) {
13182
13333
  const resolver = await ConfigResolver.create(options2.cwd ?? process.cwd(), {
13183
13334
  profile: options2.profile
13184
13335
  });
13336
+ const profileDir = getProfileDir(options2.profile);
13185
13337
  provider = {
13186
- cwd: resolver.getCwd(),
13338
+ cwd: profileDir,
13187
13339
  getRegistries: () => resolver.getRegistries(),
13188
13340
  getComponentPath: () => resolver.getComponentPath()
13189
13341
  };
@@ -13298,10 +13450,11 @@ async function handleNpmPlugins(inputs, options2, cwd) {
13298
13450
  }
13299
13451
  async function runRegistryAddCore(componentNames, options2, provider) {
13300
13452
  const cwd = provider.cwd;
13301
- const { path: lockPath } = findOcxLock(cwd);
13453
+ const isFlattened = !!options2.global || !!options2.profile;
13454
+ const { path: lockPath } = findOcxLock(cwd, { isFlattened });
13302
13455
  const registries = provider.getRegistries();
13303
13456
  let lock = { lockVersion: 1, installed: {} };
13304
- const existingLock = await readOcxLock(cwd);
13457
+ const existingLock = await readOcxLock(cwd, { isFlattened });
13305
13458
  if (existingLock) {
13306
13459
  lock = existingLock;
13307
13460
  }
@@ -13354,7 +13507,7 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13354
13507
  }
13355
13508
  const computedHash = await hashBundle(files);
13356
13509
  for (const file of component.files) {
13357
- const targetPath = join3(cwd, resolveTargetPath(file.target, !!options2.global));
13510
+ const targetPath = join3(cwd, resolveTargetPath(file.target, isFlattened));
13358
13511
  assertPathInside(targetPath, cwd);
13359
13512
  }
13360
13513
  const existingEntry = lock.installed[component.qualifiedName];
@@ -13363,7 +13516,7 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13363
13516
  throw new IntegrityError(component.qualifiedName, existingEntry.hash, computedHash);
13364
13517
  }
13365
13518
  for (const file of component.files) {
13366
- const resolvedTarget = resolveTargetPath(file.target, !!options2.global);
13519
+ const resolvedTarget = resolveTargetPath(file.target, isFlattened);
13367
13520
  const targetPath = join3(cwd, resolvedTarget);
13368
13521
  if (existsSync5(targetPath)) {
13369
13522
  const conflictingComponent = findComponentByFile(lock, resolvedTarget);
@@ -13387,7 +13540,7 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13387
13540
  const componentFile = component.files.find((f) => f.path === file.path);
13388
13541
  if (!componentFile)
13389
13542
  continue;
13390
- const resolvedTarget = resolveTargetPath(componentFile.target, !!options2.global);
13543
+ const resolvedTarget = resolveTargetPath(componentFile.target, isFlattened);
13391
13544
  const targetPath = join3(cwd, resolvedTarget);
13392
13545
  if (existsSync5(targetPath)) {
13393
13546
  const existingContent = await Bun.file(targetPath).text();
@@ -13415,7 +13568,7 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13415
13568
  const installResult = await installComponent(component, files, cwd, {
13416
13569
  force: options2.force,
13417
13570
  verbose: options2.verbose,
13418
- isGlobal: options2.global
13571
+ isFlattened
13419
13572
  });
13420
13573
  if (options2.verbose) {
13421
13574
  for (const f of installResult.skipped) {
@@ -13436,7 +13589,7 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13436
13589
  registry: component.registryName,
13437
13590
  version: index.version,
13438
13591
  hash: computedHash,
13439
- files: component.files.map((f) => resolveTargetPath(f.target, !!options2.global)),
13592
+ files: component.files.map((f) => resolveTargetPath(f.target, isFlattened)),
13440
13593
  installedAt: new Date().toISOString()
13441
13594
  };
13442
13595
  }
@@ -13453,12 +13606,12 @@ async function runRegistryAddCore(componentNames, options2, provider) {
13453
13606
  }
13454
13607
  const hasNpmDeps = resolved.npmDependencies.length > 0;
13455
13608
  const hasNpmDevDeps = resolved.npmDevDependencies.length > 0;
13456
- const packageJsonPath = options2.global ? join3(cwd, "package.json") : join3(cwd, ".opencode/package.json");
13609
+ const packageJsonPath = options2.global || options2.profile ? join3(cwd, "package.json") : join3(cwd, ".opencode/package.json");
13457
13610
  if (hasNpmDeps || hasNpmDevDeps) {
13458
13611
  const npmSpin = options2.quiet ? null : createSpinner({ text: `Updating ${packageJsonPath}...` });
13459
13612
  npmSpin?.start();
13460
13613
  try {
13461
- await updateOpencodeDevDependencies(cwd, resolved.npmDependencies, resolved.npmDevDependencies, { isGlobal: options2.global });
13614
+ await updateOpencodeDevDependencies(cwd, resolved.npmDependencies, resolved.npmDevDependencies, { isFlattened });
13462
13615
  const totalDeps = resolved.npmDependencies.length + resolved.npmDevDependencies.length;
13463
13616
  npmSpin?.succeed(`Added ${totalDeps} dependencies to ${packageJsonPath}`);
13464
13617
  } catch (error) {
@@ -13494,7 +13647,7 @@ async function installComponent(component, files, cwd, options2) {
13494
13647
  const componentFile = component.files.find((f) => f.path === file.path);
13495
13648
  if (!componentFile)
13496
13649
  continue;
13497
- const resolvedTarget = resolveTargetPath(componentFile.target, !!options2.isGlobal);
13650
+ const resolvedTarget = resolveTargetPath(componentFile.target, !!options2.isFlattened);
13498
13651
  const targetPath = join3(cwd, resolvedTarget);
13499
13652
  const targetDir = dirname(targetPath);
13500
13653
  if (existsSync5(targetPath)) {
@@ -13594,7 +13747,7 @@ async function readOpencodePackageJson(opencodeDir) {
13594
13747
  return JSON.parse(content2);
13595
13748
  } catch (e3) {
13596
13749
  const message = e3 instanceof Error ? e3.message : String(e3);
13597
- throw new ConfigError(`Invalid .opencode/package.json: ${message}`);
13750
+ throw new ConfigError(`Invalid ${pkgPath}: ${message}`);
13598
13751
  }
13599
13752
  }
13600
13753
  async function ensureManifestFilesAreTracked(opencodeDir) {
@@ -13621,14 +13774,14 @@ async function updateOpencodeDevDependencies(cwd, npmDeps, npmDevDeps, options2
13621
13774
  const allDepSpecs = [...npmDeps, ...npmDevDeps];
13622
13775
  if (allDepSpecs.length === 0)
13623
13776
  return;
13624
- const packageDir = options2.isGlobal ? cwd : join3(cwd, ".opencode");
13777
+ const packageDir = options2.isFlattened ? cwd : join3(cwd, ".opencode");
13625
13778
  await mkdir4(packageDir, { recursive: true });
13626
13779
  const parsedDeps = allDepSpecs.map(parseNpmDependency);
13627
13780
  const existing = await readOpencodePackageJson(packageDir);
13628
13781
  const updated = mergeDevDependencies(existing, parsedDeps);
13629
13782
  await Bun.write(join3(packageDir, "package.json"), `${JSON.stringify(updated, null, 2)}
13630
13783
  `);
13631
- if (!options2.isGlobal) {
13784
+ if (!options2.isFlattened) {
13632
13785
  await ensureManifestFilesAreTracked(packageDir);
13633
13786
  }
13634
13787
  }
@@ -13689,7 +13842,7 @@ async function buildRegistry(options2) {
13689
13842
  const packumentPath = join4(componentsDir, `${component.name}.json`);
13690
13843
  await Bun.write(packumentPath, JSON.stringify(packument, null, 2));
13691
13844
  for (const rawFile of component.files) {
13692
- const file = normalizeFile(rawFile);
13845
+ const file = normalizeFile(rawFile, component.type);
13693
13846
  const sourceFilePath = join4(sourcePath, "files", file.path);
13694
13847
  const destFilePath = join4(componentsDir, component.name, file.path);
13695
13848
  const destFileDir = dirname2(destFilePath);
@@ -13786,7 +13939,7 @@ import { existsSync as existsSync6 } from "fs";
13786
13939
  import { mkdir as mkdir6 } from "fs/promises";
13787
13940
  import { join as join6 } from "path";
13788
13941
  function registerConfigEditCommand(parent) {
13789
- parent.command("edit").description("Open configuration file in editor").option("-g, --global", "Edit global ocx.jsonc").action(async (options2) => {
13942
+ parent.command("edit").description("Open configuration file in editor").option("-g, --global", "Edit global ocx.jsonc").option("-p, --profile <name>", "Edit specific profile's config").action(async (options2) => {
13790
13943
  try {
13791
13944
  await runConfigEdit(options2);
13792
13945
  } catch (error) {
@@ -13795,12 +13948,26 @@ function registerConfigEditCommand(parent) {
13795
13948
  });
13796
13949
  }
13797
13950
  async function runConfigEdit(options2) {
13951
+ if (options2.global && options2.profile) {
13952
+ throw new ValidationError("Cannot use both --global and --profile flags");
13953
+ }
13798
13954
  let configPath;
13799
- if (options2.global) {
13955
+ if (options2.profile) {
13956
+ const parseResult = profileNameSchema.safeParse(options2.profile);
13957
+ if (!parseResult.success) {
13958
+ throw new ValidationError(`Invalid profile name "${options2.profile}": ${parseResult.error.errors[0]?.message ?? "Invalid name"}`);
13959
+ }
13960
+ await ProfileManager.requireInitialized();
13961
+ const manager = ProfileManager.create();
13962
+ if (!await manager.exists(options2.profile)) {
13963
+ throw new ProfileNotFoundError(options2.profile);
13964
+ }
13965
+ configPath = getProfileOcxConfig(options2.profile);
13966
+ } else if (options2.global) {
13800
13967
  configPath = getGlobalConfig();
13801
13968
  if (!existsSync6(configPath)) {
13802
13969
  throw new ConfigError(`Global config not found at ${configPath}.
13803
- ` + "Run 'opencode' once to initialize, then retry.");
13970
+ Run 'ocx init --global' first.`);
13804
13971
  }
13805
13972
  } else {
13806
13973
  const localConfigDir = findLocalConfigDir(process.cwd());
@@ -14851,11 +15018,40 @@ Diff for ${res.name}:`));
14851
15018
  }
14852
15019
 
14853
15020
  // src/commands/ghost/migrate.ts
14854
- import { copyFileSync, existsSync as existsSync7, lstatSync, readdirSync, renameSync, unlinkSync } from "fs";
15021
+ import {
15022
+ copyFileSync,
15023
+ cpSync,
15024
+ existsSync as existsSync7,
15025
+ lstatSync,
15026
+ mkdirSync,
15027
+ readdirSync,
15028
+ renameSync,
15029
+ rmdirSync,
15030
+ rmSync,
15031
+ unlinkSync
15032
+ } from "fs";
14855
15033
  import path6 from "path";
14856
15034
  var GHOST_CONFIG_FILE = "ghost.jsonc";
14857
15035
  var BACKUP_EXT = ".bak";
14858
15036
  var CURRENT_SYMLINK = "current";
15037
+ var FLATTEN_DIRS = ["plugin", "agent", "skill", "command"];
15038
+ function moveAtomically(source, destination, isDir) {
15039
+ try {
15040
+ renameSync(source, destination);
15041
+ } catch (err) {
15042
+ if (err instanceof Error && "code" in err && err.code === "EXDEV") {
15043
+ if (isDir) {
15044
+ cpSync(source, destination, { recursive: true });
15045
+ rmSync(source, { recursive: true, force: true });
15046
+ } else {
15047
+ copyFileSync(source, destination);
15048
+ unlinkSync(source);
15049
+ }
15050
+ } else {
15051
+ throw err;
15052
+ }
15053
+ }
15054
+ }
14859
15055
  function planMigration() {
14860
15056
  const profilesDir = getProfilesDir();
14861
15057
  const plan = {
@@ -14899,6 +15095,55 @@ function planMigration() {
14899
15095
  backup: backupPath
14900
15096
  });
14901
15097
  }
15098
+ for (const entry of entries) {
15099
+ if (!entry.isDirectory() || entry.name === CURRENT_SYMLINK)
15100
+ continue;
15101
+ const profileName = entry.name;
15102
+ const profileDir = path6.join(profilesDir, profileName);
15103
+ const dotOpencode = path6.join(profileDir, ".opencode");
15104
+ if (!existsSync7(dotOpencode))
15105
+ continue;
15106
+ for (const dir of FLATTEN_DIRS) {
15107
+ const source = path6.join(dotOpencode, dir);
15108
+ const destination = path6.join(profileDir, dir);
15109
+ if (!existsSync7(source))
15110
+ continue;
15111
+ try {
15112
+ const stat3 = lstatSync(source);
15113
+ if (!stat3.isDirectory()) {
15114
+ plan.skipped.push({
15115
+ profileName,
15116
+ reason: `.opencode/${dir} is not a directory`
15117
+ });
15118
+ continue;
15119
+ }
15120
+ } catch {
15121
+ continue;
15122
+ }
15123
+ if (existsSync7(destination)) {
15124
+ plan.skipped.push({
15125
+ profileName,
15126
+ reason: `${dir}/ exists in both .opencode/ and profile root`
15127
+ });
15128
+ continue;
15129
+ }
15130
+ const backupPath = source + BACKUP_EXT;
15131
+ if (existsSync7(backupPath)) {
15132
+ plan.skipped.push({
15133
+ profileName,
15134
+ reason: `backup already exists: .opencode/${dir}${BACKUP_EXT}`
15135
+ });
15136
+ continue;
15137
+ }
15138
+ plan.profiles.push({
15139
+ type: "move-dir",
15140
+ profileName,
15141
+ source,
15142
+ destination,
15143
+ backup: backupPath
15144
+ });
15145
+ }
15146
+ }
14902
15147
  const currentPath = path6.join(profilesDir, CURRENT_SYMLINK);
14903
15148
  if (existsSync7(currentPath)) {
14904
15149
  try {
@@ -14924,15 +15169,20 @@ function executeMigration(plan) {
14924
15169
  const completedRenames = [];
14925
15170
  try {
14926
15171
  for (const action of plan.profiles) {
14927
- copyFileSync(action.source, action.backup);
14928
- completedBackups.push(action.backup);
15172
+ if (action.type === "rename") {
15173
+ copyFileSync(action.source, action.backup);
15174
+ } else {
15175
+ cpSync(action.source, action.backup, { recursive: true });
15176
+ }
15177
+ completedBackups.push({ path: action.backup, isDir: action.type === "move-dir" });
14929
15178
  }
14930
15179
  for (const action of plan.profiles) {
14931
- renameSync(action.source, action.destination);
15180
+ moveAtomically(action.source, action.destination, action.type === "move-dir");
14932
15181
  completedRenames.push({
14933
15182
  source: action.source,
14934
15183
  destination: action.destination,
14935
- backup: action.backup
15184
+ backup: action.backup,
15185
+ isDir: action.type === "move-dir"
14936
15186
  });
14937
15187
  }
14938
15188
  if (plan.symlink) {
@@ -14945,32 +15195,61 @@ function executeMigration(plan) {
14945
15195
  }
14946
15196
  for (const backup of completedBackups) {
14947
15197
  try {
14948
- unlinkSync(backup);
15198
+ if (backup.isDir) {
15199
+ rmSync(backup.path, { recursive: true, force: true });
15200
+ } else {
15201
+ unlinkSync(backup.path);
15202
+ }
14949
15203
  } catch {}
14950
15204
  }
14951
15205
  } catch (error) {
14952
15206
  for (const rename2 of completedRenames) {
14953
15207
  try {
14954
15208
  if (existsSync7(rename2.destination)) {
14955
- renameSync(rename2.destination, rename2.source);
15209
+ if (rename2.isDir) {
15210
+ rmSync(rename2.destination, { recursive: true, force: true });
15211
+ } else {
15212
+ unlinkSync(rename2.destination);
15213
+ }
14956
15214
  }
14957
- } catch {
14958
- try {
14959
- if (existsSync7(rename2.backup)) {
15215
+ mkdirSync(path6.dirname(rename2.source), { recursive: true });
15216
+ if (existsSync7(rename2.backup)) {
15217
+ if (rename2.isDir) {
15218
+ cpSync(rename2.backup, rename2.source, { recursive: true });
15219
+ } else {
14960
15220
  copyFileSync(rename2.backup, rename2.source);
14961
15221
  }
14962
- } catch {}
14963
- }
15222
+ }
15223
+ } catch {}
14964
15224
  }
14965
15225
  for (const backup of completedBackups) {
14966
15226
  try {
14967
- if (existsSync7(backup)) {
14968
- unlinkSync(backup);
15227
+ if (existsSync7(backup.path)) {
15228
+ if (backup.isDir) {
15229
+ rmSync(backup.path, { recursive: true, force: true });
15230
+ } else {
15231
+ unlinkSync(backup.path);
15232
+ }
14969
15233
  }
14970
15234
  } catch {}
14971
15235
  }
14972
15236
  throw error;
14973
15237
  }
15238
+ try {
15239
+ const processedProfiles = new Set(plan.profiles.filter((a) => a.type === "move-dir").map((a) => a.profileName));
15240
+ for (const profileName of processedProfiles) {
15241
+ const profileDir = path6.join(getProfilesDir(), profileName);
15242
+ const dotOpencode = path6.join(profileDir, ".opencode");
15243
+ if (existsSync7(dotOpencode)) {
15244
+ const remaining = readdirSync(dotOpencode);
15245
+ if (remaining.length === 0) {
15246
+ try {
15247
+ rmdirSync(dotOpencode);
15248
+ } catch {}
15249
+ }
15250
+ }
15251
+ }
15252
+ } catch {}
14974
15253
  }
14975
15254
  function printPlan(plan, dryRun) {
14976
15255
  const prefix = dryRun ? "[DRY-RUN] " : "";
@@ -14983,7 +15262,12 @@ function printPlan(plan, dryRun) {
14983
15262
  if (plan.profiles.length > 0 || plan.skipped.length > 0) {
14984
15263
  console.log("Profiles:");
14985
15264
  for (const action of plan.profiles) {
14986
- console.log(` \u2713 ${action.profileName}: ${GHOST_CONFIG_FILE} \u2192 ${OCX_CONFIG_FILE}`);
15265
+ if (action.type === "rename") {
15266
+ console.log(` \u2713 ${action.profileName}: ${GHOST_CONFIG_FILE} \u2192 ${OCX_CONFIG_FILE}`);
15267
+ } else {
15268
+ const dirName = path6.basename(action.source);
15269
+ console.log(` \u2713 ${action.profileName}: .opencode/${dirName}/ \u2192 ${dirName}/`);
15270
+ }
14987
15271
  }
14988
15272
  for (const skipped of plan.skipped) {
14989
15273
  console.log(` \u26A0 ${skipped.profileName}: skipped (${skipped.reason})`);
@@ -15099,34 +15383,90 @@ async function runInit(options2) {
15099
15383
  }
15100
15384
  }
15101
15385
  async function runInitGlobal(options2) {
15102
- const manager = ProfileManager.create();
15103
- if (await manager.isInitialized()) {
15104
- const profilesDir = getProfilesDir();
15105
- throw new ProfileExistsError(`Global profiles already initialized at ${profilesDir}`);
15106
- }
15107
15386
  const spin = options2.quiet ? null : createSpinner({ text: "Initializing global profiles..." });
15108
15387
  spin?.start();
15109
15388
  try {
15110
- await manager.initialize();
15389
+ const created = [];
15390
+ const existed = [];
15391
+ const globalConfigPath = getGlobalConfig();
15392
+ if (existsSync8(globalConfigPath)) {
15393
+ existed.push("globalConfig");
15394
+ } else {
15395
+ await mkdir7(dirname3(globalConfigPath), { recursive: true, mode: 448 });
15396
+ await atomicWrite(globalConfigPath, {
15397
+ $schema: OCX_SCHEMA_URL,
15398
+ registries: {}
15399
+ });
15400
+ created.push("globalConfig");
15401
+ }
15111
15402
  const profilesDir = getProfilesDir();
15112
- const ocxConfigPath = getProfileOcxConfig("default");
15403
+ if (!existsSync8(profilesDir)) {
15404
+ await mkdir7(profilesDir, { recursive: true, mode: 448 });
15405
+ }
15406
+ const profileDir = getProfileDir("default");
15407
+ if (!existsSync8(profileDir)) {
15408
+ await mkdir7(profileDir, { recursive: true, mode: 448 });
15409
+ }
15410
+ const ocxPath = getProfileOcxConfig("default");
15411
+ if (existsSync8(ocxPath)) {
15412
+ existed.push("profileOcx");
15413
+ } else {
15414
+ await atomicWrite(ocxPath, DEFAULT_OCX_CONFIG);
15415
+ created.push("profileOcx");
15416
+ }
15417
+ const opencodePath = getProfileOpencodeConfig("default");
15418
+ if (existsSync8(opencodePath)) {
15419
+ existed.push("profileOpencode");
15420
+ } else {
15421
+ await atomicWrite(opencodePath, {});
15422
+ created.push("profileOpencode");
15423
+ }
15424
+ const agentsPath = getProfileAgents("default");
15425
+ if (existsSync8(agentsPath)) {
15426
+ existed.push("profileAgents");
15427
+ } else {
15428
+ const agentsContent = `# Profile Instructions
15429
+
15430
+ <!-- Add your custom instructions for this profile here -->
15431
+ <!-- These will be included when running \`ocx opencode -p default\` -->
15432
+ `;
15433
+ await Bun.write(agentsPath, agentsContent, { mode: 384 });
15434
+ created.push("profileAgents");
15435
+ }
15113
15436
  spin?.succeed("Initialized global profiles");
15114
15437
  if (options2.json) {
15115
15438
  console.log(JSON.stringify({
15116
15439
  success: true,
15117
- profilesDir,
15118
- defaultProfile: "default",
15119
- ocxConfigPath
15440
+ files: {
15441
+ globalConfig: globalConfigPath,
15442
+ profileOcx: ocxPath,
15443
+ profileOpencode: opencodePath,
15444
+ profileAgents: agentsPath
15445
+ },
15446
+ created,
15447
+ existed
15120
15448
  }));
15121
15449
  } else if (!options2.quiet) {
15122
- logger.info(`Created ${profilesDir}`);
15123
- logger.info(`Created profile "default"`);
15124
- logger.info("");
15125
- logger.info("Next steps:");
15126
- logger.info(" 1. Edit your profile config: ocx profile config");
15127
- logger.info(" 2. Add registries: ocx registry add <url> --profile default");
15128
- logger.info(" 3. Launch OpenCode: ocx opencode");
15129
- logger.info(" 4. Create more profiles: ocx profile add <name>");
15450
+ if (created.length > 0) {
15451
+ for (const key of created) {
15452
+ if (key === "globalConfig")
15453
+ logger.info(`Created global config: ${globalConfigPath}`);
15454
+ if (key === "profileOcx")
15455
+ logger.info(`Created profile config: ${ocxPath}`);
15456
+ if (key === "profileOpencode")
15457
+ logger.info(`Created profile opencode config: ${opencodePath}`);
15458
+ if (key === "profileAgents")
15459
+ logger.info(`Created profile instructions: ${agentsPath}`);
15460
+ }
15461
+ logger.info("");
15462
+ logger.info("Next steps:");
15463
+ logger.info(" 1. Edit your profile config: ocx config edit -p default");
15464
+ logger.info(" 2. Add registries: ocx registry add <url> --name <name> --global");
15465
+ logger.info(" 3. Launch OpenCode: ocx opencode");
15466
+ logger.info(" 4. Create more profiles: ocx profile add <name>");
15467
+ } else {
15468
+ logger.info("Global profiles already initialized (all files exist)");
15469
+ }
15130
15470
  }
15131
15471
  } catch (error) {
15132
15472
  spin?.fail("Failed to initialize");
@@ -15154,7 +15494,7 @@ async function runInitRegistry(directory, options2) {
15154
15494
  await mkdir7(cwd, { recursive: true });
15155
15495
  await copyDir(options2.local, cwd);
15156
15496
  } else {
15157
- const version = options2.canary ? "main" : await getLatestVersion();
15497
+ const version = options2.canary ? "main" : getReleaseTag();
15158
15498
  await fetchAndExtractTemplate(cwd, version, options2.verbose);
15159
15499
  }
15160
15500
  if (spin)
@@ -15183,15 +15523,16 @@ async function runInitRegistry(directory, options2) {
15183
15523
  async function copyDir(src, dest) {
15184
15524
  await cp(src, dest, { recursive: true });
15185
15525
  }
15186
- async function getLatestVersion() {
15187
- const pkgPath = new URL("../../package.json", import.meta.url);
15188
- const pkgContent = await readFile(pkgPath);
15189
- const pkg = JSON.parse(pkgContent.toString());
15190
- return `v${pkg.version}`;
15526
+ function getReleaseTag() {
15527
+ if (false) {}
15528
+ return `v${"1.4.2"}`;
15191
15529
  }
15192
- async function fetchAndExtractTemplate(destDir, version, verbose) {
15530
+ function getTemplateUrl(version) {
15193
15531
  const ref = version === "main" ? "heads/main" : `tags/${version}`;
15194
- const tarballUrl = `https://github.com/${TEMPLATE_REPO}/archive/refs/${ref}.tar.gz`;
15532
+ return `https://github.com/${TEMPLATE_REPO}/archive/refs/${ref}.tar.gz`;
15533
+ }
15534
+ async function fetchAndExtractTemplate(destDir, version, verbose) {
15535
+ const tarballUrl = getTemplateUrl(version);
15195
15536
  if (verbose) {
15196
15537
  logger.info(`Fetching ${tarballUrl}`);
15197
15538
  }
@@ -15300,6 +15641,18 @@ function formatTerminalName(cwd, profileName, gitInfo) {
15300
15641
  }
15301
15642
 
15302
15643
  // src/commands/opencode.ts
15644
+ function resolveOpenCodeBinary(opts) {
15645
+ return opts.configBin ?? opts.envBin ?? "opencode";
15646
+ }
15647
+ function buildOpenCodeEnv(opts) {
15648
+ return {
15649
+ ...opts.baseEnv,
15650
+ ...opts.disableProjectConfig && { OPENCODE_DISABLE_PROJECT_CONFIG: "true" },
15651
+ ...opts.profileDir && { OPENCODE_CONFIG_DIR: opts.profileDir },
15652
+ ...opts.mergedConfig && { OPENCODE_CONFIG_CONTENT: JSON.stringify(opts.mergedConfig) },
15653
+ ...opts.profileName && { OCX_PROFILE: opts.profileName }
15654
+ };
15655
+ }
15303
15656
  function registerOpencodeCommand(program2) {
15304
15657
  program2.command("opencode [path]").alias("oc").description("Launch OpenCode with resolved configuration").option("-p, --profile <name>", "Use specific profile").option("--no-rename", "Disable terminal/tmux window renaming").addOption(sharedOptions.quiet()).addOption(sharedOptions.json()).allowUnknownOption().allowExcessArguments(true).action(async (path8, options2, command) => {
15305
15658
  try {
@@ -15354,17 +15707,20 @@ async function runOpencode(pathArg, args, options2) {
15354
15707
  const gitInfo = await getGitInfo(projectDir);
15355
15708
  setTerminalName(formatTerminalName(projectDir, config.profileName ?? "default", gitInfo));
15356
15709
  }
15357
- const bin = ocxConfig?.bin ?? process.env.OPENCODE_BIN ?? "opencode";
15710
+ const bin = resolveOpenCodeBinary({
15711
+ configBin: ocxConfig?.bin,
15712
+ envBin: process.env.OPENCODE_BIN
15713
+ });
15358
15714
  proc = Bun.spawn({
15359
15715
  cmd: [bin, ...args],
15360
15716
  cwd: projectDir,
15361
- env: {
15362
- ...process.env,
15363
- OPENCODE_DISABLE_PROJECT_CONFIG: "true",
15364
- ...profileDir && { OPENCODE_CONFIG_DIR: profileDir },
15365
- ...configToPass && { OPENCODE_CONFIG_CONTENT: JSON.stringify(configToPass) },
15366
- ...config.profileName && { OCX_PROFILE: config.profileName }
15367
- },
15717
+ env: buildOpenCodeEnv({
15718
+ baseEnv: process.env,
15719
+ profileDir,
15720
+ profileName: config.profileName ?? undefined,
15721
+ mergedConfig: configToPass,
15722
+ disableProjectConfig: true
15723
+ }),
15368
15724
  stdin: "inherit",
15369
15725
  stdout: "inherit",
15370
15726
  stderr: "inherit"
@@ -15389,9 +15745,290 @@ async function runOpencode(pathArg, args, options2) {
15389
15745
  }
15390
15746
  }
15391
15747
 
15748
+ // src/commands/profile/install-from-registry.ts
15749
+ import { createHash as createHash2 } from "crypto";
15750
+ import { existsSync as existsSync9 } from "fs";
15751
+ import { mkdir as mkdir8, mkdtemp, rename as rename2, rm as rm3, writeFile as writeFile3 } from "fs/promises";
15752
+ import { dirname as dirname4, join as join8 } from "path";
15753
+ var PROFILE_FILE_TARGETS = new Set(["ocx.jsonc", "opencode.jsonc", "AGENTS.md"]);
15754
+ function isProfileFile(target) {
15755
+ return PROFILE_FILE_TARGETS.has(target);
15756
+ }
15757
+ function hashContent2(content2) {
15758
+ return createHash2("sha256").update(content2).digest("hex");
15759
+ }
15760
+ function hashBundle2(files) {
15761
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
15762
+ const manifestParts = [];
15763
+ for (const file of sorted) {
15764
+ const hash = hashContent2(file.content);
15765
+ manifestParts.push(`${file.path}:${hash}`);
15766
+ }
15767
+ return hashContent2(manifestParts.join(`
15768
+ `));
15769
+ }
15770
+ async function installProfileFromRegistry(options2) {
15771
+ const { namespace, component, profileName, force, registryUrl, registries, quiet } = options2;
15772
+ const parseResult = profileNameSchema.safeParse(profileName);
15773
+ if (!parseResult.success) {
15774
+ throw new ValidationError(`Invalid profile name: "${profileName}". ` + `Profile names must start with a letter and contain only alphanumeric characters, dots, underscores, or hyphens.`);
15775
+ }
15776
+ const profileDir = getProfileDir(profileName);
15777
+ const qualifiedName = `${namespace}/${component}`;
15778
+ const profileExists = existsSync9(profileDir);
15779
+ if (profileExists && !force) {
15780
+ throw new ConflictError(`Profile "${profileName}" already exists.
15781
+ Use --force to overwrite.`);
15782
+ }
15783
+ const fetchSpin = quiet ? null : createSpinner({ text: `Fetching ${qualifiedName}...` });
15784
+ fetchSpin?.start();
15785
+ let manifest;
15786
+ try {
15787
+ manifest = await fetchComponent(registryUrl, component);
15788
+ } catch (error) {
15789
+ fetchSpin?.fail(`Failed to fetch ${qualifiedName}`);
15790
+ if (error instanceof NotFoundError) {
15791
+ throw new NotFoundError(`Profile component "${qualifiedName}" not found in registry.
15792
+
15793
+ ` + `Check the component name and ensure the registry is configured.`);
15794
+ }
15795
+ throw error;
15796
+ }
15797
+ if (manifest.type !== "ocx:profile") {
15798
+ fetchSpin?.fail(`Invalid component type`);
15799
+ throw new ValidationError(`Component "${qualifiedName}" is type "${manifest.type}", not "ocx:profile".
15800
+
15801
+ ` + `Only profile components can be installed with 'ocx profile add --from'.`);
15802
+ }
15803
+ const normalized = normalizeComponentManifest(manifest);
15804
+ fetchSpin?.succeed(`Fetched ${qualifiedName}`);
15805
+ const filesSpin = quiet ? null : createSpinner({ text: "Downloading profile files..." });
15806
+ filesSpin?.start();
15807
+ const profileFiles = [];
15808
+ const dependencyFiles = [];
15809
+ for (const file of normalized.files) {
15810
+ const content2 = await fetchFileContent(registryUrl, component, file.path);
15811
+ const fileEntry = {
15812
+ path: file.path,
15813
+ target: file.target,
15814
+ content: Buffer.from(content2)
15815
+ };
15816
+ if (isProfileFile(file.target)) {
15817
+ profileFiles.push(fileEntry);
15818
+ } else {
15819
+ dependencyFiles.push(fileEntry);
15820
+ }
15821
+ }
15822
+ filesSpin?.succeed(`Downloaded ${normalized.files.length} files`);
15823
+ let resolvedDeps = null;
15824
+ const dependencyBundles = [];
15825
+ if (manifest.dependencies.length > 0) {
15826
+ const depsSpin = quiet ? null : createSpinner({ text: "Resolving dependencies..." });
15827
+ depsSpin?.start();
15828
+ try {
15829
+ const depRefs = manifest.dependencies.map((dep) => dep.includes("/") ? dep : `${namespace}/${dep}`);
15830
+ resolvedDeps = await resolveDependencies(registries, depRefs);
15831
+ for (const depComponent of resolvedDeps.components) {
15832
+ const files = [];
15833
+ for (const file of depComponent.files) {
15834
+ const content2 = await fetchFileContent(depComponent.baseUrl, depComponent.name, file.path);
15835
+ const resolvedTarget = resolveTargetPath(file.target, true);
15836
+ files.push({
15837
+ path: file.path,
15838
+ target: resolvedTarget,
15839
+ content: Buffer.from(content2)
15840
+ });
15841
+ }
15842
+ const registryIndex = await fetchRegistryIndex(depComponent.baseUrl);
15843
+ dependencyBundles.push({
15844
+ qualifiedName: depComponent.qualifiedName,
15845
+ registryName: depComponent.registryName,
15846
+ files,
15847
+ hash: hashBundle2(files),
15848
+ version: registryIndex.version
15849
+ });
15850
+ }
15851
+ depsSpin?.succeed(`Resolved ${resolvedDeps.components.length} dependencies`);
15852
+ } catch (error) {
15853
+ depsSpin?.fail("Failed to resolve dependencies");
15854
+ throw error;
15855
+ }
15856
+ }
15857
+ const profilesDir = getProfilesDir();
15858
+ await mkdir8(profilesDir, { recursive: true, mode: 448 });
15859
+ const stagingDir = await mkdtemp(join8(profilesDir, ".staging-"));
15860
+ const stagingOpencodeDir = join8(stagingDir, ".opencode");
15861
+ try {
15862
+ await mkdir8(stagingOpencodeDir, { recursive: true, mode: 448 });
15863
+ const writeSpin = quiet ? null : createSpinner({ text: "Writing profile files..." });
15864
+ writeSpin?.start();
15865
+ for (const file of profileFiles) {
15866
+ const targetPath = join8(stagingDir, file.target);
15867
+ const targetDir = dirname4(targetPath);
15868
+ if (!existsSync9(targetDir)) {
15869
+ await mkdir8(targetDir, { recursive: true });
15870
+ }
15871
+ await writeFile3(targetPath, file.content);
15872
+ }
15873
+ for (const file of dependencyFiles) {
15874
+ const target = file.target.startsWith(".opencode/") ? file.target.slice(".opencode/".length) : file.target;
15875
+ const targetPath = join8(stagingOpencodeDir, target);
15876
+ const targetDir = dirname4(targetPath);
15877
+ if (!existsSync9(targetDir)) {
15878
+ await mkdir8(targetDir, { recursive: true });
15879
+ }
15880
+ await writeFile3(targetPath, file.content);
15881
+ }
15882
+ writeSpin?.succeed(`Wrote ${profileFiles.length + dependencyFiles.length} profile files`);
15883
+ if (dependencyBundles.length > 0) {
15884
+ const depWriteSpin = quiet ? null : createSpinner({ text: "Writing dependency files..." });
15885
+ depWriteSpin?.start();
15886
+ let depFileCount = 0;
15887
+ for (const bundle of dependencyBundles) {
15888
+ for (const file of bundle.files) {
15889
+ const targetPath = join8(stagingOpencodeDir, file.target);
15890
+ const targetDir = dirname4(targetPath);
15891
+ if (!existsSync9(targetDir)) {
15892
+ await mkdir8(targetDir, { recursive: true });
15893
+ }
15894
+ await writeFile3(targetPath, file.content);
15895
+ depFileCount++;
15896
+ }
15897
+ }
15898
+ depWriteSpin?.succeed(`Wrote ${depFileCount} dependency files`);
15899
+ }
15900
+ const profileHash = hashBundle2(profileFiles.map((f) => ({ path: f.path, content: f.content })));
15901
+ const registryIndex = await fetchRegistryIndex(registryUrl);
15902
+ const lock = {
15903
+ version: 1,
15904
+ installedFrom: {
15905
+ registry: namespace,
15906
+ component,
15907
+ version: registryIndex.version,
15908
+ hash: profileHash,
15909
+ installedAt: new Date().toISOString()
15910
+ },
15911
+ installed: {}
15912
+ };
15913
+ for (const bundle of dependencyBundles) {
15914
+ lock.installed[bundle.qualifiedName] = {
15915
+ registry: bundle.registryName,
15916
+ version: bundle.version,
15917
+ hash: bundle.hash,
15918
+ files: bundle.files.map((f) => f.target),
15919
+ installedAt: new Date().toISOString()
15920
+ };
15921
+ }
15922
+ await writeFile3(join8(stagingDir, "ocx.lock"), JSON.stringify(lock, null, "\t"));
15923
+ const moveSpin = quiet ? null : createSpinner({ text: "Finalizing installation..." });
15924
+ moveSpin?.start();
15925
+ const profilesDir2 = dirname4(profileDir);
15926
+ if (!existsSync9(profilesDir2)) {
15927
+ await mkdir8(profilesDir2, { recursive: true, mode: 448 });
15928
+ }
15929
+ if (profileExists && force) {
15930
+ const backupDir = `${profileDir}.backup-${Date.now()}`;
15931
+ await rename2(profileDir, backupDir);
15932
+ try {
15933
+ await rename2(stagingDir, profileDir);
15934
+ } catch (err) {
15935
+ await rename2(backupDir, profileDir);
15936
+ throw err;
15937
+ }
15938
+ await rm3(backupDir, { recursive: true, force: true });
15939
+ } else {
15940
+ await rename2(stagingDir, profileDir);
15941
+ }
15942
+ moveSpin?.succeed("Installation complete");
15943
+ if (!quiet) {
15944
+ logger.info("");
15945
+ logger.success(`Installed profile "${profileName}" from ${qualifiedName}`);
15946
+ logger.info("");
15947
+ logger.info("Profile contents:");
15948
+ for (const file of profileFiles) {
15949
+ logger.info(` ${file.target}`);
15950
+ }
15951
+ if (dependencyBundles.length > 0) {
15952
+ logger.info("");
15953
+ logger.info("Dependencies:");
15954
+ for (const bundle of dependencyBundles) {
15955
+ logger.info(` ${bundle.qualifiedName}`);
15956
+ }
15957
+ }
15958
+ }
15959
+ } catch (error) {
15960
+ try {
15961
+ if (existsSync9(stagingDir)) {
15962
+ await rm3(stagingDir, { recursive: true });
15963
+ }
15964
+ } catch {}
15965
+ throw error;
15966
+ }
15967
+ }
15968
+
15392
15969
  // src/commands/profile/add.ts
15970
+ function parseFromOption(from) {
15971
+ if (!from?.trim()) {
15972
+ throw new ValidationError("--from value cannot be empty");
15973
+ }
15974
+ const trimmed = from.trim();
15975
+ if (trimmed.startsWith("./") || trimmed.startsWith("~/") || trimmed.startsWith("/")) {
15976
+ return { type: "local-path", path: trimmed };
15977
+ }
15978
+ const slashCount = (trimmed.match(/\//g) || []).length;
15979
+ if (slashCount === 1) {
15980
+ const [namespace, component] = trimmed.split("/").map((s) => s.trim());
15981
+ if (!namespace || !component) {
15982
+ throw new ValidationError(`Invalid registry reference: "${from}". Expected format: namespace/component`);
15983
+ }
15984
+ return { type: "registry", namespace, component };
15985
+ }
15986
+ return { type: "local-profile", name: trimmed };
15987
+ }
15988
+ async function readGlobalOcxConfig() {
15989
+ const configPath = getGlobalConfig();
15990
+ const file = Bun.file(configPath);
15991
+ if (!await file.exists()) {
15992
+ return null;
15993
+ }
15994
+ try {
15995
+ const content2 = await file.text();
15996
+ const json = parse2(content2, [], { allowTrailingComma: true });
15997
+ return profileOcxConfigSchema.parse(json);
15998
+ } catch (error) {
15999
+ if (error instanceof Error) {
16000
+ throw new ConfigError(`Failed to parse global config at "${configPath}": ${error.message}
16001
+
16002
+ ` + `Check the file for syntax errors or run: ocx config edit --global`);
16003
+ }
16004
+ throw error;
16005
+ }
16006
+ }
16007
+ async function requireGlobalRegistry(namespace) {
16008
+ const globalConfig = await readGlobalOcxConfig();
16009
+ if (!globalConfig) {
16010
+ throw new ConfigError(`Registry "${namespace}" is not configured globally.
16011
+
16012
+ ` + `Profile installation requires global registry configuration.
16013
+ ` + `Run: ocx registry add <url> --name ${namespace} --global`);
16014
+ }
16015
+ const registry = globalConfig.registries[namespace];
16016
+ if (!registry) {
16017
+ throw new ConfigError(`Registry "${namespace}" is not configured globally.
16018
+
16019
+ ` + `Profile installation requires global registry configuration.
16020
+ ` + `Run: ocx registry add <url> --name ${namespace} --global`);
16021
+ }
16022
+ return { config: globalConfig, registryUrl: registry.url };
16023
+ }
15393
16024
  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) => {
16025
+ 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", `
16026
+ Examples:
16027
+ $ ocx profile add work # Create empty profile
16028
+ $ ocx profile add work --from dev # Clone from existing profile
16029
+ $ ocx profile add work --from kdco/minimal # Install from registry
16030
+ $ ocx profile add work --from kdco/minimal --force # Overwrite existing
16031
+ `).action(async (name, options2) => {
15395
16032
  try {
15396
16033
  await runProfileAdd(name, options2);
15397
16034
  } catch (error) {
@@ -15401,42 +16038,61 @@ function registerProfileAddCommand(parent) {
15401
16038
  }
15402
16039
  async function runProfileAdd(name, options2) {
15403
16040
  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}"`);
16041
+ const profileExists = await manager.exists(name);
16042
+ if (profileExists && !options2.force) {
16043
+ logger.error(`\u2717 Profile "${name}" already exists`);
16044
+ logger.error("");
16045
+ logger.error("Use --force to overwrite the existing profile.");
16046
+ throw new ProfileExistsError(name);
16047
+ }
16048
+ if (!options2.from) {
16049
+ await createEmptyProfile(manager, name, profileExists);
16050
+ return;
15412
16051
  }
15413
- }
16052
+ const fromInput = parseFromOption(options2.from);
16053
+ switch (fromInput.type) {
16054
+ case "local-profile":
16055
+ await cloneFromLocalProfile(manager, name, fromInput.name, profileExists);
16056
+ break;
16057
+ case "local-path":
16058
+ throw new ValidationError(`Local path installation is not yet implemented: "${fromInput.path}"
15414
16059
 
15415
- // src/commands/profile/config.ts
15416
- function registerProfileConfigCommand(parent) {
15417
- parent.command("config [name]").description("Open profile ocx.jsonc in editor").action(async (name) => {
15418
- try {
15419
- await runProfileConfig(name);
15420
- } catch (error) {
15421
- handleError(error);
16060
+ ` + `Currently supported sources:
16061
+ ` + ` - Existing profile: --from <profile-name>
16062
+ ` + ` - Registry: --from <namespace>/<component>`);
16063
+ case "registry": {
16064
+ const { config: globalConfig, registryUrl } = await requireGlobalRegistry(fromInput.namespace);
16065
+ const registries = {};
16066
+ for (const [ns, reg] of Object.entries(globalConfig.registries)) {
16067
+ registries[ns] = { url: reg.url };
16068
+ }
16069
+ await installProfileFromRegistry({
16070
+ namespace: fromInput.namespace,
16071
+ component: fromInput.component,
16072
+ profileName: name,
16073
+ force: options2.force,
16074
+ registryUrl,
16075
+ registries
16076
+ });
16077
+ break;
15422
16078
  }
15423
- });
16079
+ }
15424
16080
  }
15425
- async function runProfileConfig(name) {
15426
- const manager = await ProfileManager.requireInitialized();
15427
- const profileName = name ?? await manager.resolveProfile();
15428
- await manager.get(profileName);
15429
- const configPath = getProfileOcxConfig(profileName);
15430
- const editor = process.env.EDITOR || process.env.VISUAL || "vi";
15431
- const proc = Bun.spawn([editor, configPath], {
15432
- stdin: "inherit",
15433
- stdout: "inherit",
15434
- stderr: "inherit"
15435
- });
15436
- const exitCode = await proc.exited;
15437
- if (exitCode !== 0) {
15438
- throw new Error(`Editor exited with code ${exitCode}`);
16081
+ async function createEmptyProfile(manager, name, exists) {
16082
+ if (exists) {
16083
+ await manager.remove(name);
16084
+ }
16085
+ await manager.add(name);
16086
+ logger.success(`Created profile "${name}"`);
16087
+ }
16088
+ async function cloneFromLocalProfile(manager, name, sourceName, exists) {
16089
+ const source = await manager.get(sourceName);
16090
+ if (exists) {
16091
+ await manager.remove(name);
15439
16092
  }
16093
+ await manager.add(name);
16094
+ await atomicWrite(getProfileOcxConfig(name), source.ocx);
16095
+ logger.success(`Created profile "${name}" (cloned from "${sourceName}")`);
15440
16096
  }
15441
16097
 
15442
16098
  // src/commands/profile/list.ts
@@ -15525,22 +16181,43 @@ function registerProfileCommand(program2) {
15525
16181
  registerProfileAddCommand(profile);
15526
16182
  registerProfileRemoveCommand(profile);
15527
16183
  registerProfileShowCommand(profile);
15528
- registerProfileConfigCommand(profile);
15529
16184
  }
15530
16185
 
15531
16186
  // src/commands/registry.ts
16187
+ import { existsSync as existsSync10 } from "fs";
16188
+ import { dirname as dirname5, join as join9 } from "path";
15532
16189
  async function runRegistryAddCore2(url, options2, callbacks) {
15533
16190
  if (callbacks.isLocked?.()) {
15534
16191
  throw new Error("Registries are locked. Cannot add.");
15535
16192
  }
15536
- const name = options2.name || new URL(url).hostname.replace(/\./g, "-");
16193
+ const trimmedUrl = url.trim();
16194
+ if (!trimmedUrl) {
16195
+ throw new ValidationError("Registry URL is required");
16196
+ }
16197
+ let derivedName;
16198
+ try {
16199
+ const parsed = new URL(trimmedUrl);
16200
+ if (!["http:", "https:"].includes(parsed.protocol)) {
16201
+ throw new ValidationError(`Invalid registry URL: ${trimmedUrl} (must use http or https)`);
16202
+ }
16203
+ derivedName = options2.name || parsed.hostname.replace(/\./g, "-");
16204
+ } catch (error) {
16205
+ if (error instanceof ValidationError)
16206
+ throw error;
16207
+ throw new ValidationError(`Invalid registry URL: ${trimmedUrl}`);
16208
+ }
16209
+ const name = derivedName;
15537
16210
  const registries = callbacks.getRegistries();
16211
+ const existingRegistry = registries[name];
16212
+ if (existingRegistry && !options2.force) {
16213
+ throw new RegistryExistsError(name, existingRegistry.url, trimmedUrl);
16214
+ }
15538
16215
  const isUpdate = name in registries;
15539
16216
  await callbacks.setRegistry(name, {
15540
- url,
16217
+ url: trimmedUrl,
15541
16218
  version: options2.version
15542
16219
  });
15543
- return { name, url, updated: isUpdate };
16220
+ return { name, url: trimmedUrl, updated: isUpdate };
15544
16221
  }
15545
16222
  async function runRegistryRemoveCore(name, callbacks) {
15546
16223
  if (callbacks.isLocked?.()) {
@@ -15563,17 +16240,67 @@ function runRegistryListCore(callbacks) {
15563
16240
  }));
15564
16241
  return { registries: list, locked };
15565
16242
  }
16243
+ async function resolveRegistryTarget(options2, command, cwd) {
16244
+ const cwdExplicitlyProvided = command.getOptionValueSource("cwd") === "cli";
16245
+ if (options2.global && options2.profile) {
16246
+ throw new ValidationError("Cannot use both --global and --profile flags");
16247
+ }
16248
+ if (cwdExplicitlyProvided && options2.profile) {
16249
+ throw new ValidationError("Cannot use both --cwd and --profile flags");
16250
+ }
16251
+ if (options2.global && cwdExplicitlyProvided) {
16252
+ throw new ValidationError("Cannot use both --global and --cwd flags");
16253
+ }
16254
+ if (options2.profile) {
16255
+ validateProfileName(options2.profile);
16256
+ const manager = await ProfileManager.requireInitialized();
16257
+ if (!await manager.exists(options2.profile)) {
16258
+ throw new ProfileNotFoundError(options2.profile);
16259
+ }
16260
+ const configPath = getProfileOcxConfig(options2.profile);
16261
+ if (!existsSync10(configPath)) {
16262
+ throw new OcxConfigError(`Profile '${options2.profile}' has no ocx.jsonc. Run 'ocx profile config ${options2.profile}' to create it.`);
16263
+ }
16264
+ return {
16265
+ scope: "profile",
16266
+ configPath,
16267
+ configDir: dirname5(configPath),
16268
+ targetLabel: `profile '${options2.profile}' config`
16269
+ };
16270
+ }
16271
+ if (options2.global) {
16272
+ const configDir = getGlobalConfigPath();
16273
+ return {
16274
+ scope: "global",
16275
+ configPath: join9(configDir, "ocx.jsonc"),
16276
+ configDir,
16277
+ targetLabel: "global config"
16278
+ };
16279
+ }
16280
+ const found = findOcxConfig(cwd);
16281
+ return {
16282
+ scope: "local",
16283
+ configPath: found.path,
16284
+ configDir: found.exists ? dirname5(found.path) : join9(cwd, ".opencode"),
16285
+ targetLabel: "local config"
16286
+ };
16287
+ }
15566
16288
  function registerRegistryCommand(program2) {
15567
16289
  const registry = program2.command("registry").description("Manage registries");
15568
- 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");
16290
+ 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").option("-f, --force", "Overwrite existing registry");
16291
+ addGlobalOption(addCmd);
16292
+ addProfileOption(addCmd);
15569
16293
  addCommonOptions(addCmd);
15570
- addCmd.action(async (url, options2) => {
16294
+ addCmd.action(async (url, options2, command) => {
16295
+ let target;
15571
16296
  try {
15572
16297
  const cwd = options2.cwd ?? process.cwd();
15573
- const { path: configPath } = findOcxConfig(cwd);
15574
- const config = await readOcxConfig(cwd);
16298
+ target = await resolveRegistryTarget(options2, command, cwd);
16299
+ const { configDir, configPath } = target;
16300
+ const config = await readOcxConfig(configDir);
15575
16301
  if (!config) {
15576
- logger.error("No ocx.jsonc found. Run 'ocx init' first.");
16302
+ const initHint = target.scope === "global" ? "Run 'ocx init --global' first." : target.scope === "profile" ? `Run 'ocx profile config ${options2.profile}' to create it.` : "Run 'ocx init' first.";
16303
+ logger.error(`${target.targetLabel} not found. ${initHint}`);
15577
16304
  process.exit(1);
15578
16305
  }
15579
16306
  const result = await runRegistryAddCore2(url, options2, {
@@ -15581,31 +16308,38 @@ function registerRegistryCommand(program2) {
15581
16308
  isLocked: () => config.lockRegistries ?? false,
15582
16309
  setRegistry: async (name, regConfig) => {
15583
16310
  config.registries[name] = regConfig;
15584
- await writeOcxConfig(cwd, config, configPath);
16311
+ await writeOcxConfig(configDir, config, configPath);
15585
16312
  }
15586
16313
  });
15587
16314
  if (options2.json) {
15588
16315
  outputJson({ success: true, data: result });
15589
16316
  } else if (!options2.quiet) {
15590
16317
  if (result.updated) {
15591
- logger.success(`Updated registry: ${result.name} -> ${result.url}`);
16318
+ logger.success(`Updated registry in ${target.targetLabel}: ${result.name} -> ${result.url}`);
15592
16319
  } else {
15593
- logger.success(`Added registry: ${result.name} -> ${result.url}`);
16320
+ logger.success(`Added registry to ${target.targetLabel}: ${result.name} -> ${result.url}`);
15594
16321
  }
15595
16322
  }
15596
16323
  } catch (error) {
16324
+ if (error instanceof RegistryExistsError && !error.targetLabel) {
16325
+ const enrichedError = new RegistryExistsError(error.registryName, error.existingUrl, error.newUrl, target?.targetLabel ?? "config");
16326
+ handleError(enrichedError, { json: options2.json });
16327
+ }
15597
16328
  handleError(error, { json: options2.json });
15598
16329
  }
15599
16330
  });
15600
16331
  const removeCmd = registry.command("remove").description("Remove a registry").argument("<name>", "Registry name");
16332
+ addGlobalOption(removeCmd);
16333
+ addProfileOption(removeCmd);
15601
16334
  addCommonOptions(removeCmd);
15602
- removeCmd.action(async (name, options2) => {
16335
+ removeCmd.action(async (name, options2, command) => {
15603
16336
  try {
15604
16337
  const cwd = options2.cwd ?? process.cwd();
15605
- const { path: configPath } = findOcxConfig(cwd);
15606
- const config = await readOcxConfig(cwd);
16338
+ const target = await resolveRegistryTarget(options2, command, cwd);
16339
+ const config = await readOcxConfig(target.configDir);
15607
16340
  if (!config) {
15608
- logger.error("No ocx.jsonc found. Run 'ocx init' first.");
16341
+ const initHint = target.scope === "global" ? "Run 'ocx init --global' first." : target.scope === "profile" ? `Run 'ocx profile config ${options2.profile}' to create it.` : "Run 'ocx init' first.";
16342
+ logger.error(`${target.targetLabel} not found. ${initHint}`);
15609
16343
  process.exit(1);
15610
16344
  }
15611
16345
  const result = await runRegistryRemoveCore(name, {
@@ -15613,26 +16347,30 @@ function registerRegistryCommand(program2) {
15613
16347
  isLocked: () => config.lockRegistries ?? false,
15614
16348
  removeRegistry: async (regName) => {
15615
16349
  delete config.registries[regName];
15616
- await writeOcxConfig(cwd, config, configPath);
16350
+ await writeOcxConfig(target.configDir, config, target.configPath);
15617
16351
  }
15618
16352
  });
15619
16353
  if (options2.json) {
15620
16354
  outputJson({ success: true, data: result });
15621
16355
  } else if (!options2.quiet) {
15622
- logger.success(`Removed registry: ${result.removed}`);
16356
+ logger.success(`Removed registry from ${target.targetLabel}: ${result.removed}`);
15623
16357
  }
15624
16358
  } catch (error) {
15625
16359
  handleError(error, { json: options2.json });
15626
16360
  }
15627
16361
  });
15628
16362
  const listCmd = registry.command("list").description("List configured registries");
16363
+ addGlobalOption(listCmd);
16364
+ addProfileOption(listCmd);
15629
16365
  addCommonOptions(listCmd);
15630
- listCmd.action(async (options2) => {
16366
+ listCmd.action(async (options2, command) => {
15631
16367
  try {
15632
16368
  const cwd = options2.cwd ?? process.cwd();
15633
- const config = await readOcxConfig(cwd);
16369
+ const target = await resolveRegistryTarget(options2, command, cwd);
16370
+ const config = await readOcxConfig(target.configDir);
15634
16371
  if (!config) {
15635
- logger.warn("No ocx.jsonc found. Run 'ocx init' first.");
16372
+ const initHint = target.scope === "global" ? "Run 'ocx init --global' first." : target.scope === "profile" ? `Run 'ocx profile config ${options2.profile}' to create it.` : "Run 'ocx init' first.";
16373
+ logger.warn(`${target.targetLabel} not found. ${initHint}`);
15636
16374
  return;
15637
16375
  }
15638
16376
  const result = runRegistryListCore({
@@ -15645,7 +16383,8 @@ function registerRegistryCommand(program2) {
15645
16383
  if (result.registries.length === 0) {
15646
16384
  logger.info("No registries configured.");
15647
16385
  } else {
15648
- logger.info(`Configured registries${result.locked ? kleur_default.yellow(" (locked)") : ""}:`);
16386
+ const scopeLabel = target.scope === "global" ? " (global)" : target.scope === "profile" ? ` (profile '${options2.profile}')` : "";
16387
+ logger.info(`Configured registries${scopeLabel}${result.locked ? kleur_default.yellow(" (locked)") : ""}:`);
15649
16388
  for (const reg of result.registries) {
15650
16389
  console.log(` ${kleur_default.cyan(reg.name)}: ${reg.url} ${kleur_default.dim(`(${reg.version})`)}`);
15651
16390
  }
@@ -15780,7 +16519,7 @@ async function runSearchCore(query, options2, provider) {
15780
16519
 
15781
16520
  // src/self-update/version-provider.ts
15782
16521
  class BuildTimeVersionProvider {
15783
- version = "1.4.0";
16522
+ version = "1.4.2";
15784
16523
  }
15785
16524
  var defaultVersionProvider = new BuildTimeVersionProvider;
15786
16525
 
@@ -15893,7 +16632,7 @@ function getExecutablePath() {
15893
16632
  }
15894
16633
 
15895
16634
  // src/self-update/download.ts
15896
- import { chmodSync, existsSync as existsSync9, renameSync as renameSync2, unlinkSync as unlinkSync2 } from "fs";
16635
+ import { chmodSync, existsSync as existsSync11, renameSync as renameSync2, unlinkSync as unlinkSync2 } from "fs";
15897
16636
  var GITHUB_REPO2 = "kdcokenny/ocx";
15898
16637
  var DEFAULT_DOWNLOAD_BASE_URL = `https://github.com/${GITHUB_REPO2}/releases/download`;
15899
16638
  var PLATFORM_MAP = {
@@ -15971,7 +16710,7 @@ async function downloadToTemp(version) {
15971
16710
  try {
15972
16711
  chmodSync(tempPath, 493);
15973
16712
  } catch (error) {
15974
- if (existsSync9(tempPath)) {
16713
+ if (existsSync11(tempPath)) {
15975
16714
  unlinkSync2(tempPath);
15976
16715
  }
15977
16716
  throw new SelfUpdateError(`Failed to set permissions: ${error instanceof Error ? error.message : String(error)}`);
@@ -15981,20 +16720,20 @@ async function downloadToTemp(version) {
15981
16720
  function atomicReplace(tempPath, execPath) {
15982
16721
  const backupPath = `${execPath}.backup`;
15983
16722
  try {
15984
- if (existsSync9(execPath)) {
16723
+ if (existsSync11(execPath)) {
15985
16724
  renameSync2(execPath, backupPath);
15986
16725
  }
15987
16726
  renameSync2(tempPath, execPath);
15988
- if (existsSync9(backupPath)) {
16727
+ if (existsSync11(backupPath)) {
15989
16728
  unlinkSync2(backupPath);
15990
16729
  }
15991
16730
  } catch (error) {
15992
- if (existsSync9(backupPath) && !existsSync9(execPath)) {
16731
+ if (existsSync11(backupPath) && !existsSync11(execPath)) {
15993
16732
  try {
15994
16733
  renameSync2(backupPath, execPath);
15995
16734
  } catch {}
15996
16735
  }
15997
- if (existsSync9(tempPath)) {
16736
+ if (existsSync11(tempPath)) {
15998
16737
  try {
15999
16738
  unlinkSync2(tempPath);
16000
16739
  } catch {}
@@ -16003,7 +16742,7 @@ function atomicReplace(tempPath, execPath) {
16003
16742
  }
16004
16743
  }
16005
16744
  function cleanupTempFile(tempPath) {
16006
- if (existsSync9(tempPath)) {
16745
+ if (existsSync11(tempPath)) {
16007
16746
  try {
16008
16747
  unlinkSync2(tempPath);
16009
16748
  } catch {}
@@ -16025,7 +16764,7 @@ function notifyUpdated(from, to) {
16025
16764
  }
16026
16765
 
16027
16766
  // src/self-update/verify.ts
16028
- import { createHash as createHash2 } from "crypto";
16767
+ import { createHash as createHash3 } from "crypto";
16029
16768
  var GITHUB_REPO3 = "kdcokenny/ocx";
16030
16769
  function parseSha256Sums(content2) {
16031
16770
  const checksums = new Map;
@@ -16038,8 +16777,8 @@ function parseSha256Sums(content2) {
16038
16777
  }
16039
16778
  return checksums;
16040
16779
  }
16041
- function hashContent2(content2) {
16042
- return createHash2("sha256").update(content2).digest("hex");
16780
+ function hashContent3(content2) {
16781
+ return createHash3("sha256").update(content2).digest("hex");
16043
16782
  }
16044
16783
  async function fetchChecksums(version) {
16045
16784
  const url = `https://github.com/${GITHUB_REPO3}/releases/download/v${version}/SHA256SUMS.txt`;
@@ -16053,7 +16792,7 @@ async function fetchChecksums(version) {
16053
16792
  async function verifyChecksum(filePath, expectedHash, filename) {
16054
16793
  const file = Bun.file(filePath);
16055
16794
  const content2 = await file.arrayBuffer();
16056
- const actualHash = hashContent2(Buffer.from(content2));
16795
+ const actualHash = hashContent3(Buffer.from(content2));
16057
16796
  if (actualHash !== expectedHash) {
16058
16797
  throw new IntegrityError(filename, expectedHash, actualHash);
16059
16798
  }
@@ -16178,10 +16917,10 @@ function registerSelfCommand(program2) {
16178
16917
  }
16179
16918
 
16180
16919
  // 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";
16920
+ import { createHash as createHash4 } from "crypto";
16921
+ import { existsSync as existsSync12 } from "fs";
16922
+ import { mkdir as mkdir9, writeFile as writeFile4 } from "fs/promises";
16923
+ import { dirname as dirname6, join as join10 } from "path";
16185
16924
  function registerUpdateCommand(program2) {
16186
16925
  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
16926
  try {
@@ -16265,7 +17004,7 @@ Version cannot be empty. Use 'kdco/agents@1.2.0' or omit the version for latest.
16265
17004
  const content2 = await fetchFileContent(regConfig.url, componentName, file.path);
16266
17005
  files.push({ path: file.path, content: Buffer.from(content2) });
16267
17006
  }
16268
- const newHash = await hashBundle2(files);
17007
+ const newHash = await hashBundle3(files);
16269
17008
  if (newHash === lockEntry.hash) {
16270
17009
  results.push({
16271
17010
  qualifiedName,
@@ -16319,12 +17058,12 @@ Version cannot be empty. Use 'kdco/agents@1.2.0' or omit the version for latest.
16319
17058
  const fileObj = update.component.files.find((f) => f.path === file.path);
16320
17059
  if (!fileObj)
16321
17060
  continue;
16322
- const targetPath = join8(provider.cwd, fileObj.target);
16323
- const targetDir = dirname4(targetPath);
16324
- if (!existsSync10(targetDir)) {
16325
- await mkdir8(targetDir, { recursive: true });
17061
+ const targetPath = join10(provider.cwd, fileObj.target);
17062
+ const targetDir = dirname6(targetPath);
17063
+ if (!existsSync12(targetDir)) {
17064
+ await mkdir9(targetDir, { recursive: true });
16326
17065
  }
16327
- await writeFile3(targetPath, file.content);
17066
+ await writeFile4(targetPath, file.content);
16328
17067
  if (options2.verbose) {
16329
17068
  logger.info(` \u2713 Updated ${fileObj.target}`);
16330
17069
  }
@@ -16443,17 +17182,17 @@ function outputDryRun(results, options2) {
16443
17182
  }
16444
17183
  }
16445
17184
  }
16446
- async function hashContent3(content2) {
16447
- return createHash3("sha256").update(content2).digest("hex");
17185
+ async function hashContent4(content2) {
17186
+ return createHash4("sha256").update(content2).digest("hex");
16448
17187
  }
16449
- async function hashBundle2(files) {
17188
+ async function hashBundle3(files) {
16450
17189
  const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
16451
17190
  const manifestParts = [];
16452
17191
  for (const file of sorted) {
16453
- const hash = await hashContent3(file.content);
17192
+ const hash = await hashContent4(file.content);
16454
17193
  manifestParts.push(`${file.path}:${hash}`);
16455
17194
  }
16456
- return hashContent3(manifestParts.join(`
17195
+ return hashContent4(manifestParts.join(`
16457
17196
  `));
16458
17197
  }
16459
17198
 
@@ -16485,7 +17224,7 @@ function registerUpdateCheckHook(program2) {
16485
17224
  });
16486
17225
  }
16487
17226
  // src/index.ts
16488
- var version = "1.4.0";
17227
+ var version = "1.4.2";
16489
17228
  async function main2() {
16490
17229
  const program2 = new Command().name("ocx").description("OpenCode Extensions - Install agents, skills, plugins, and commands").version(version);
16491
17230
  registerInitCommand(program2);
@@ -16517,4 +17256,4 @@ export {
16517
17256
  buildRegistry
16518
17257
  };
16519
17258
 
16520
- //# debugId=5FADDAC480C59C7864756E2164756E21
17259
+ //# debugId=52AD8D268EBA9E8864756E2164756E21