@hi-man/himan 0.3.5 → 0.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.
@@ -8,9 +8,13 @@ import { toRepoId } from "../utils/repo-id.js";
8
8
  import { HimanError, errorCodes } from "../utils/errors.js";
9
9
  import { getGlobalResourcePaths, getProjectResourcePaths, getSupportedAgentNames, normalizeAgents, } from "../utils/agent-configs.js";
10
10
  import path from "node:path";
11
+ import { execFile } from "node:child_process";
11
12
  import { promises as fs } from "node:fs";
13
+ import { promisify } from "node:util";
12
14
  import { VersionResolver } from "../adapters/version/version-resolver.js";
13
15
  import YAML from "yaml";
16
+ const execFileAsync = promisify(execFile);
17
+ const RESOURCE_TYPES = ["rule", "command", "skill"];
14
18
  export class ServiceFactory {
15
19
  stateStore = new StateStore();
16
20
  projectConfigStore = new ProjectConfigStore();
@@ -43,9 +47,7 @@ export class ServiceFactory {
43
47
  };
44
48
  }
45
49
  async addSource(name, type, repo) {
46
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
47
- throw new HimanError(errorCodes.INVALID_INPUT, `Invalid source name: ${name}`);
48
- }
50
+ this.validateSourceName(name);
49
51
  await this.stateStore.ensureBaseDirs();
50
52
  const sourceConfig = this.buildSourceConfig(type, repo);
51
53
  const source = this.createSource(type);
@@ -105,6 +107,47 @@ export class ServiceFactory {
105
107
  const source = await this.loadSourceFromConfig();
106
108
  return source.initDocs(options);
107
109
  }
110
+ async cloneSource(from, to, options = {}) {
111
+ await this.stateStore.ensureBaseDirs();
112
+ this.validateSourceTransferOptions(to, options);
113
+ const sourceEndpoint = await this.resolveGitSourceEndpoint(from);
114
+ const targetEndpoint = await this.resolveGitSourceEndpoint(to);
115
+ this.validateSourceTransferUseTarget(targetEndpoint, options);
116
+ this.ensureDifferentGitSources(sourceEndpoint, targetEndpoint);
117
+ const source = await this.loadGitSourceFromEndpoint(sourceEndpoint);
118
+ const result = await source.cloneTo(targetEndpoint.repo, {
119
+ branch: options.branch,
120
+ targetBranch: options.targetBranch,
121
+ dryRun: options.dryRun,
122
+ });
123
+ const configUpdates = await this.applySourceTransferConfigUpdates(targetEndpoint, options);
124
+ return {
125
+ source: sourceEndpoint,
126
+ target: targetEndpoint,
127
+ ...result,
128
+ ...configUpdates,
129
+ };
130
+ }
131
+ async syncSource(from, to, options = {}) {
132
+ await this.stateStore.ensureBaseDirs();
133
+ this.validateSourceTransferOptions(to, options);
134
+ const sourceEndpoint = await this.resolveGitSourceEndpoint(from);
135
+ const targetEndpoint = await this.resolveGitSourceEndpoint(to);
136
+ this.validateSourceTransferUseTarget(targetEndpoint, options);
137
+ this.ensureDifferentGitSources(sourceEndpoint, targetEndpoint);
138
+ const source = await this.loadGitSourceFromEndpoint(sourceEndpoint);
139
+ const result = await source.syncLatestTo(targetEndpoint.repo, {
140
+ targetBranch: options.targetBranch,
141
+ dryRun: options.dryRun,
142
+ });
143
+ const configUpdates = await this.applySourceTransferConfigUpdates(targetEndpoint, options);
144
+ return {
145
+ source: sourceEndpoint,
146
+ target: targetEndpoint,
147
+ ...result,
148
+ ...configUpdates,
149
+ };
150
+ }
108
151
  async setAgents(agents, scope, projectDir) {
109
152
  const normalized = normalizeAgents(agents);
110
153
  if (scope === "project") {
@@ -137,6 +180,45 @@ export class ServiceFactory {
137
180
  supported: getSupportedAgentNames(),
138
181
  };
139
182
  }
183
+ async doctor(projectDir) {
184
+ const checks = [];
185
+ checks.push(this.checkNodeVersion());
186
+ checks.push(await this.checkGit());
187
+ checks.push(await this.checkHomeState());
188
+ const config = await this.stateStore.loadConfig();
189
+ if (!config?.source) {
190
+ checks.push({
191
+ name: "source",
192
+ status: "error",
193
+ message: "No source configured. Run `himan init <git_repo>` first.",
194
+ details: { configPath: this.stateStore.getConfigPath() },
195
+ });
196
+ }
197
+ else {
198
+ const currentName = config.sources?.default ?? "default";
199
+ const currentSource = config.sources?.items[currentName] ?? config.source;
200
+ checks.push({
201
+ name: "source",
202
+ status: "ok",
203
+ message: `Using ${currentSource.type} source ${currentName}.`,
204
+ details: {
205
+ name: currentName,
206
+ type: currentSource.type,
207
+ repo: currentSource.repo,
208
+ repoId: currentSource.repoId,
209
+ },
210
+ });
211
+ checks.push(await this.checkSourceResources());
212
+ }
213
+ checks.push(await this.checkAgents(projectDir));
214
+ const lockCheck = await this.checkProjectLock(projectDir);
215
+ checks.push(lockCheck.check);
216
+ checks.push(await this.checkProjectTargets(projectDir, lockCheck.lock));
217
+ return {
218
+ ok: !checks.some((check) => check.status === "error"),
219
+ checks,
220
+ };
221
+ }
140
222
  async clearAgents(scope, projectDir) {
141
223
  if (scope === "project") {
142
224
  await this.projectConfigStore.clearAgents(projectDir);
@@ -149,6 +231,172 @@ export class ServiceFactory {
149
231
  });
150
232
  return { scope };
151
233
  }
234
+ checkNodeVersion() {
235
+ const version = process.versions.node;
236
+ const major = Number(version.split(".")[0]);
237
+ if (major === 22) {
238
+ return {
239
+ name: "node",
240
+ status: "ok",
241
+ message: `Node.js ${version}.`,
242
+ };
243
+ }
244
+ return {
245
+ name: "node",
246
+ status: "error",
247
+ message: `Node.js ${version} is unsupported. Use Node.js 22.x.`,
248
+ };
249
+ }
250
+ async checkGit() {
251
+ try {
252
+ const result = await execFileAsync("git", ["--version"]);
253
+ return {
254
+ name: "git",
255
+ status: "ok",
256
+ message: result.stdout.trim() || "Git is available.",
257
+ };
258
+ }
259
+ catch (error) {
260
+ return this.errorCheck("git", "Git is not available on PATH.", error);
261
+ }
262
+ }
263
+ async checkHomeState() {
264
+ const root = this.paths.getHimanRoot();
265
+ const reposDir = this.paths.getReposDir();
266
+ const storeDir = this.paths.getStoreDir();
267
+ const missing = [];
268
+ if (!(await this.exists(root)))
269
+ missing.push(root);
270
+ if (!(await this.exists(reposDir)))
271
+ missing.push(reposDir);
272
+ if (!(await this.exists(storeDir)))
273
+ missing.push(storeDir);
274
+ if (missing.length > 0) {
275
+ return {
276
+ name: "home",
277
+ status: "warn",
278
+ message: "Himan home directories are not fully initialized yet.",
279
+ details: { root, reposDir, storeDir, missing },
280
+ };
281
+ }
282
+ return {
283
+ name: "home",
284
+ status: "ok",
285
+ message: `Himan home is initialized at ${root}.`,
286
+ details: { root, reposDir, storeDir },
287
+ };
288
+ }
289
+ async checkSourceResources() {
290
+ try {
291
+ const source = await this.loadSourceFromConfig();
292
+ const entries = await Promise.all(RESOURCE_TYPES.map(async (type) => [type, await source.list(type)]));
293
+ const counts = Object.fromEntries(entries.map(([type, resources]) => [type, resources.length]));
294
+ const total = RESOURCE_TYPES.reduce((sum, type) => sum + counts[type], 0);
295
+ return {
296
+ name: "resources",
297
+ status: "ok",
298
+ message: `Scanned ${total} resources from current source.`,
299
+ details: { counts },
300
+ };
301
+ }
302
+ catch (error) {
303
+ return this.errorCheck("resources", "Cannot scan current source resources.", error);
304
+ }
305
+ }
306
+ async checkAgents(projectDir) {
307
+ try {
308
+ const settings = await this.getAgentSettings(projectDir);
309
+ const scope = settings.project ? "project" : settings.global ? "global" : "default";
310
+ return {
311
+ name: "agents",
312
+ status: "ok",
313
+ message: `Effective agents: ${settings.effective.join(", ")} (${scope}).`,
314
+ details: settings,
315
+ };
316
+ }
317
+ catch (error) {
318
+ return this.errorCheck("agents", "Cannot resolve effective agents.", error);
319
+ }
320
+ }
321
+ async checkProjectLock(projectDir) {
322
+ const lockPath = this.lockStore.getLockPath(projectDir);
323
+ const { lock, state } = await this.lockStore.loadWithState(projectDir);
324
+ if (state === "missing") {
325
+ return {
326
+ check: {
327
+ name: "lock",
328
+ status: "ok",
329
+ message: "No himan.lock found for this project yet.",
330
+ details: { lockPath },
331
+ },
332
+ };
333
+ }
334
+ if (state === "invalid" || !lock) {
335
+ return {
336
+ check: {
337
+ name: "lock",
338
+ status: "error",
339
+ message: `Lock file is invalid: ${lockPath}`,
340
+ details: { lockPath },
341
+ },
342
+ };
343
+ }
344
+ if (lock.resources.length === 0) {
345
+ return {
346
+ check: {
347
+ name: "lock",
348
+ status: "warn",
349
+ message: "himan.lock has no resources.",
350
+ details: { lockPath },
351
+ },
352
+ lock,
353
+ };
354
+ }
355
+ return {
356
+ check: {
357
+ name: "lock",
358
+ status: "ok",
359
+ message: `himan.lock tracks ${lock.resources.length} resources.`,
360
+ details: { lockPath, source: lock.source },
361
+ },
362
+ lock,
363
+ };
364
+ }
365
+ async checkProjectTargets(projectDir, lock) {
366
+ if (!lock || lock.resources.length === 0) {
367
+ return {
368
+ name: "targets",
369
+ status: "ok",
370
+ message: "No locked project targets to verify.",
371
+ };
372
+ }
373
+ const missing = [];
374
+ for (const resource of lock.resources) {
375
+ const agents = normalizeAgents(resource.agents);
376
+ const targets = getProjectResourcePaths(projectDir, resource.type, resource.name, agents);
377
+ for (const targetPath of targets) {
378
+ if (!(await this.exists(targetPath))) {
379
+ missing.push({
380
+ resource: `${resource.type}/${resource.name}@${resource.version}`,
381
+ path: targetPath,
382
+ });
383
+ }
384
+ }
385
+ }
386
+ if (missing.length > 0) {
387
+ return {
388
+ name: "targets",
389
+ status: "warn",
390
+ message: `Missing ${missing.length} installed targets. Run \`himan install\` to restore them.`,
391
+ details: { missing },
392
+ };
393
+ }
394
+ return {
395
+ name: "targets",
396
+ status: "ok",
397
+ message: "All locked project targets exist.",
398
+ };
399
+ }
152
400
  async list(type, agents) {
153
401
  const source = await this.loadSourceFromConfig();
154
402
  const resources = await source.list(type);
@@ -192,22 +440,33 @@ export class ServiceFactory {
192
440
  return this.installWithSource(source, undefined, type, name, version, projectDir, agents, mode, "global");
193
441
  }
194
442
  async dev(type, name, projectDir) {
195
- const installInfo = await this.resolveInstalledResource(projectDir, type, name);
196
- const installedPath = installInfo.installedPath;
197
- const devPath = this.getProjectDevPath(projectDir, type, name);
198
- if (!(await this.exists(devPath))) {
199
- await fs.mkdir(path.dirname(devPath), { recursive: true });
200
- await fs.cp(installedPath, devPath, { recursive: true });
443
+ const projectTarget = await this.tryResolveProjectResourceTarget(projectDir, type, name);
444
+ if (projectTarget) {
445
+ return {
446
+ type,
447
+ name,
448
+ devPath: projectTarget.resourcePath,
449
+ linkPath: projectTarget.linkPaths[0],
450
+ mode: projectTarget.mode,
451
+ sourceScope: "project",
452
+ };
201
453
  }
202
- for (const linkPath of installInfo.linkPaths) {
203
- await this.materializeResource(devPath, linkPath, installInfo.mode);
454
+ const globalTarget = await this.tryResolveGlobalResourceTarget(projectDir, type, name);
455
+ if (!globalTarget) {
456
+ throw new HimanError(errorCodes.INSTALL_NOT_FOUND, `Installed resource link not found for ${type}/${name}. Run install first.`);
457
+ }
458
+ const projectLinkPaths = getProjectResourcePaths(projectDir, type, name, globalTarget.agents);
459
+ for (const [index, linkPath] of projectLinkPaths.entries()) {
460
+ const sourcePath = globalTarget.linkPaths[index] ?? globalTarget.resourcePath;
461
+ await this.materializeResource(sourcePath, linkPath, "copy");
204
462
  }
205
463
  return {
206
464
  type,
207
465
  name,
208
- devPath,
209
- linkPath: installInfo.linkPaths[0],
210
- mode: installInfo.mode,
466
+ devPath: projectLinkPaths[0],
467
+ linkPath: projectLinkPaths[0],
468
+ mode: "copy",
469
+ sourceScope: "global",
211
470
  };
212
471
  }
213
472
  async uninstall(type, name, projectDir) {
@@ -221,58 +480,142 @@ export class ServiceFactory {
221
480
  await this.lockStore.removeResource(projectDir, { type, name });
222
481
  return { type, name, linkPath: installInfo.linkPaths[0] };
223
482
  }
224
- async publish(type, name, releaseType, projectDir) {
483
+ async publish(type, name, releaseType, projectDir, options = {}) {
484
+ const installScope = options.installScope ?? "project";
485
+ this.reportPublishProgress(options, "prepare", `Preparing ${type}/${name}.`);
225
486
  const source = await this.loadSourceFromConfig();
226
487
  const sourceDir = await this.resolvePublishSourceDir(type, name, projectDir);
227
488
  const existingInstallInfo = await this.tryResolveInstalledResource(projectDir, type, name);
489
+ const existingGlobalInstallInfo = await this.tryResolveGlobalResourceTarget(projectDir, type, name);
228
490
  const history = await source.history(type, name);
229
491
  const latest = history[0]?.version ?? "0.0.0";
230
492
  const nextVersion = this.versions.nextVersion(latest, releaseType);
493
+ this.reportPublishProgress(options, "resolve-version", `Resolved ${releaseType} version ${nextVersion}.`);
494
+ this.reportPublishProgress(options, "publish-source", `Publishing ${type}/${name}@${nextVersion} to the Git source.`);
231
495
  const result = await source.publish(type, name, nextVersion, sourceDir, {
232
496
  releaseType,
233
497
  });
234
498
  const storePath = this.getStorePath(type, name, nextVersion);
499
+ this.reportPublishProgress(options, "sync-store", `Syncing ${type}/${name}@${nextVersion} into the local store.`);
235
500
  if (!(await this.exists(storePath))) {
236
501
  await source.pull(type, name, nextVersion, storePath);
237
502
  }
238
503
  const locked = await this.getLockedResource(projectDir, type, name);
239
504
  const resourceMeta = await this.readResourceMetaFromDir(storePath, type);
240
505
  const configuredAgents = await this.getConfiguredAgents(projectDir);
241
- const nextAgents = locked?.agents?.length
242
- ? normalizeAgents(locked.agents)
243
- : existingInstallInfo?.agents.length
244
- ? normalizeAgents(existingInstallInfo.agents)
245
- : configuredAgents ?? normalizeAgents(resourceMeta?.agents);
246
506
  const installMode = "copy";
247
- const linkPaths = getProjectResourcePaths(projectDir, type, name, nextAgents);
507
+ const nextAgents = installScope === "global"
508
+ ? existingGlobalInstallInfo?.agents.length
509
+ ? normalizeAgents(existingGlobalInstallInfo.agents)
510
+ : existingInstallInfo?.agents.length
511
+ ? normalizeAgents(existingInstallInfo.agents)
512
+ : locked?.agents?.length
513
+ ? normalizeAgents(locked.agents)
514
+ : configuredAgents ?? normalizeAgents(resourceMeta?.agents)
515
+ : locked?.agents?.length
516
+ ? normalizeAgents(locked.agents)
517
+ : existingInstallInfo?.agents.length
518
+ ? normalizeAgents(existingInstallInfo.agents)
519
+ : configuredAgents ?? normalizeAgents(resourceMeta?.agents);
520
+ const linkPaths = installScope === "global"
521
+ ? getGlobalResourcePaths(this.paths.getHomeDir(), type, name, nextAgents)
522
+ : getProjectResourcePaths(projectDir, type, name, nextAgents);
523
+ this.reportPublishProgress(options, "install", installScope === "global"
524
+ ? `Installing published version globally for ${nextAgents.join(", ")}.`
525
+ : `Installing published version into the current project for ${nextAgents.join(", ")}.`);
248
526
  for (const linkPath of linkPaths) {
249
527
  await this.materializeResource(storePath, linkPath, installMode);
250
528
  }
251
- const sourceInfo = await this.getLockSourceInfo();
252
- await this.lockStore.upsertResource(projectDir, sourceInfo, {
253
- type,
254
- name,
255
- version: nextVersion,
256
- agents: nextAgents,
257
- mode: installMode,
258
- });
529
+ if (installScope === "project") {
530
+ const sourceInfo = await this.getLockSourceInfo();
531
+ await this.lockStore.upsertResource(projectDir, sourceInfo, {
532
+ type,
533
+ name,
534
+ version: nextVersion,
535
+ agents: nextAgents,
536
+ mode: installMode,
537
+ });
538
+ }
539
+ this.reportPublishProgress(options, "cleanup", `Cleaning up legacy dev copy if present.`);
259
540
  await fs.rm(this.getProjectDevPath(projectDir, type, name), {
260
541
  recursive: true,
261
542
  force: true,
262
543
  });
263
- return { type, name, version: result.version, tag: result.tag };
544
+ this.reportPublishProgress(options, "done", `Published ${type}/${name}@${result.version}.`);
545
+ return {
546
+ type,
547
+ name,
548
+ version: result.version,
549
+ tag: result.tag,
550
+ installScope,
551
+ linkPath: linkPaths[0],
552
+ };
264
553
  }
265
554
  async create(type, name, options, projectDir) {
266
555
  this.validateCreateInput(type, name, options);
556
+ await this.loadSourceFromConfig();
557
+ const agents = await this.resolveEffectiveAgents(projectDir, options.agents);
558
+ const resourcePaths = getProjectResourcePaths(projectDir, type, name, agents);
559
+ const entry = options.entry ?? this.getDefaultEntry(type);
560
+ const files = resourcePaths.flatMap((resourcePath) => [
561
+ path.join(resourcePath, "himan.yaml"),
562
+ path.join(resourcePath, entry),
563
+ ]);
564
+ const existingPaths = [];
565
+ for (const resourcePath of resourcePaths) {
566
+ if (await this.exists(resourcePath))
567
+ existingPaths.push(resourcePath);
568
+ }
569
+ if (existingPaths.length > 0 && !options.force) {
570
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, `Resource already exists: ${type}/${name}`, { paths: existingPaths });
571
+ }
572
+ if (!options.dryRun) {
573
+ for (const resourcePath of resourcePaths) {
574
+ await fs.rm(resourcePath, { recursive: true, force: true });
575
+ await fs.mkdir(resourcePath, { recursive: true });
576
+ await fs.writeFile(path.join(resourcePath, "himan.yaml"), YAML.stringify({
577
+ name,
578
+ type,
579
+ version: "0.1.0",
580
+ entry,
581
+ description: options.description ?? `${type} resource ${name}`,
582
+ agents,
583
+ }), "utf8");
584
+ await fs.writeFile(path.join(resourcePath, entry), this.getDefaultContent(type, name), "utf8");
585
+ }
586
+ }
587
+ return {
588
+ type,
589
+ name,
590
+ resourceDir: resourcePaths[0],
591
+ files,
592
+ dryRun: Boolean(options.dryRun),
593
+ };
594
+ }
595
+ async rename(type, oldName, newName, projectDir, options = {}) {
596
+ this.validateRenameInput(type, oldName, newName);
267
597
  const source = await this.loadSourceFromConfig();
268
- return source.create(type, name, {
269
- description: options.description,
270
- agents: await this.resolveEffectiveAgents(projectDir, options.agents),
271
- entry: options.entry,
272
- template: options.template ?? "basic",
273
- force: options.force,
598
+ const shouldMigrateProject = options.migrateProject !== false && !options.dryRun;
599
+ const locked = shouldMigrateProject
600
+ ? await this.getLockedResource(projectDir, type, oldName)
601
+ : undefined;
602
+ const installInfo = shouldMigrateProject
603
+ ? await this.tryResolveInstalledResource(projectDir, type, oldName)
604
+ : undefined;
605
+ const hasDevPath = shouldMigrateProject &&
606
+ (await this.exists(this.getProjectDevPath(projectDir, type, oldName)));
607
+ if (shouldMigrateProject && (locked || installInfo || hasDevPath)) {
608
+ await this.ensureRenamedProjectResourceAvailable(projectDir, type, oldName, newName, locked, installInfo);
609
+ }
610
+ const result = await source.rename(type, oldName, newName, {
274
611
  dryRun: options.dryRun,
275
612
  });
613
+ const projectMigrated = shouldMigrateProject &&
614
+ (await this.migrateRenamedProjectResource(source, type, oldName, newName, projectDir, result, locked, installInfo));
615
+ return {
616
+ ...result,
617
+ projectMigrated,
618
+ };
276
619
  }
277
620
  async installFromLock(projectDir, agents, mode) {
278
621
  const { lock, state } = await this.lockStore.loadWithState(projectDir);
@@ -331,6 +674,76 @@ export class ServiceFactory {
331
674
  async loadSourceFromConfig() {
332
675
  return (await this.loadSourceWithInfoFromConfig()).source;
333
676
  }
677
+ async resolveGitSourceEndpoint(ref) {
678
+ const config = await this.stateStore.loadConfig();
679
+ const configured = config?.sources?.items[ref];
680
+ if (configured) {
681
+ if (configured.type !== "git" || !configured.repo) {
682
+ throw new HimanError(errorCodes.INVALID_INPUT, `Source is not a git source: ${ref}`);
683
+ }
684
+ return {
685
+ name: ref,
686
+ repo: configured.repo,
687
+ repoId: configured.repoId ?? toRepoId(configured.repo),
688
+ };
689
+ }
690
+ if (!ref.trim()) {
691
+ throw new HimanError(errorCodes.INVALID_INPUT, "Git repo is required.");
692
+ }
693
+ return {
694
+ repo: ref,
695
+ repoId: toRepoId(ref),
696
+ };
697
+ }
698
+ async loadGitSourceFromEndpoint(endpoint) {
699
+ const sourceConfig = this.buildSourceConfig("git", endpoint.repo, endpoint.repoId);
700
+ const source = new GitSourceAdapter();
701
+ await source.init(sourceConfig);
702
+ return source;
703
+ }
704
+ validateSourceTransferOptions(targetRef, options) {
705
+ if (options.addSource) {
706
+ this.validateSourceName(options.addSource);
707
+ }
708
+ if (!options.use || options.addSource) {
709
+ return;
710
+ }
711
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(targetRef)) {
712
+ throw new HimanError(errorCodes.INVALID_INPUT, "`--use` requires the target to be a configured source name or `--add-source <name>`.");
713
+ }
714
+ }
715
+ validateSourceTransferUseTarget(target, options) {
716
+ if (options.use && !options.addSource && !target.name) {
717
+ throw new HimanError(errorCodes.INVALID_INPUT, "`--use` requires the target to be a configured source name or `--add-source <name>`.");
718
+ }
719
+ }
720
+ ensureDifferentGitSources(source, target) {
721
+ if (source.repo === target.repo) {
722
+ throw new HimanError(errorCodes.INVALID_INPUT, "Source and target repositories must be different.");
723
+ }
724
+ }
725
+ async applySourceTransferConfigUpdates(target, options) {
726
+ if (options.dryRun)
727
+ return {};
728
+ let addedSource;
729
+ if (options.addSource) {
730
+ await this.addSource(options.addSource, "git", target.repo);
731
+ addedSource = options.addSource;
732
+ }
733
+ const sourceToUse = options.use ? options.addSource ?? target.name : undefined;
734
+ if (sourceToUse) {
735
+ await this.useSource(sourceToUse);
736
+ }
737
+ return {
738
+ addedSource,
739
+ usedSource: sourceToUse,
740
+ };
741
+ }
742
+ validateSourceName(name) {
743
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
744
+ throw new HimanError(errorCodes.INVALID_INPUT, `Invalid source name: ${name}`);
745
+ }
746
+ }
334
747
  async loadSourceWithInfoFromConfig() {
335
748
  const { name, source: stateSource } = await this.getCurrentSourceState();
336
749
  const sourceInfo = this.toLockSourceInfo(stateSource, name);
@@ -391,6 +804,75 @@ export class ServiceFactory {
391
804
  return undefined;
392
805
  return lock.resources.find((item) => item.type === type && item.name === name);
393
806
  }
807
+ async ensureRenamedProjectResourceAvailable(projectDir, type, oldName, newName, locked, installInfo) {
808
+ const lockedNewName = await this.getLockedResource(projectDir, type, newName);
809
+ if (lockedNewName) {
810
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, `Installed resource already exists: ${type}/${newName}`);
811
+ }
812
+ const newDevPath = this.getProjectDevPath(projectDir, type, newName);
813
+ if (await this.exists(newDevPath)) {
814
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, `Development resource already exists: ${type}/${newName}`);
815
+ }
816
+ const agents = locked?.agents?.length
817
+ ? normalizeAgents(locked.agents)
818
+ : installInfo?.agents;
819
+ if (!agents?.length)
820
+ return;
821
+ for (const linkPath of getProjectResourcePaths(projectDir, type, newName, agents)) {
822
+ if (await this.exists(linkPath)) {
823
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, `Installed resource already exists: ${type}/${newName}`);
824
+ }
825
+ }
826
+ }
827
+ async migrateRenamedProjectResource(source, type, oldName, newName, projectDir, result, locked, installInfo) {
828
+ const oldDevPath = this.getProjectDevPath(projectDir, type, oldName);
829
+ const newDevPath = this.getProjectDevPath(projectDir, type, newName);
830
+ const hasDevPath = await this.exists(oldDevPath);
831
+ if (!locked && !installInfo && !hasDevPath) {
832
+ return false;
833
+ }
834
+ let sourcePath;
835
+ if (hasDevPath) {
836
+ await fs.mkdir(path.dirname(newDevPath), { recursive: true });
837
+ await fs.rename(oldDevPath, newDevPath);
838
+ await this.updateRenamedResourceMetadata(newDevPath, type, oldName, newName);
839
+ sourcePath = newDevPath;
840
+ }
841
+ else if (result.latestVersion) {
842
+ const storePath = this.getStorePath(type, newName, result.latestVersion);
843
+ if (!(await this.exists(storePath))) {
844
+ await source.pull(type, newName, result.latestVersion, storePath);
845
+ }
846
+ sourcePath = storePath;
847
+ }
848
+ else if (installInfo) {
849
+ sourcePath = installInfo.installedPath;
850
+ }
851
+ else {
852
+ sourcePath = result.resourceDir;
853
+ }
854
+ const agents = locked?.agents?.length
855
+ ? normalizeAgents(locked.agents)
856
+ : installInfo?.agents ?? normalizeAgents();
857
+ const mode = installInfo?.mode ?? this.resolveInstallMode(locked?.mode);
858
+ const oldLinkPaths = installInfo?.linkPaths ?? getProjectResourcePaths(projectDir, type, oldName, agents);
859
+ const newLinkPaths = getProjectResourcePaths(projectDir, type, newName, agents);
860
+ for (const linkPath of newLinkPaths) {
861
+ await this.materializeResource(sourcePath, linkPath, mode);
862
+ }
863
+ for (const linkPath of oldLinkPaths) {
864
+ await fs.rm(linkPath, { recursive: true, force: true });
865
+ }
866
+ if (locked) {
867
+ await this.lockStore.renameResource(projectDir, {
868
+ type,
869
+ oldName,
870
+ newName,
871
+ version: result.latestVersion ?? locked.version,
872
+ });
873
+ }
874
+ return true;
875
+ }
394
876
  buildSourceConfig(type, repo, repoId) {
395
877
  if (type === "registry") {
396
878
  return { type };
@@ -433,6 +915,80 @@ export class ServiceFactory {
433
915
  resolveInstallMode(mode) {
434
916
  return mode === "link" ? "link" : "copy";
435
917
  }
918
+ reportPublishProgress(options, stage, message) {
919
+ options.onProgress?.({ stage, message });
920
+ }
921
+ async tryResolveProjectResourceTarget(projectDir, type, name) {
922
+ const locked = await this.getLockedResource(projectDir, type, name);
923
+ const configuredAgents = await this.getConfiguredAgents(projectDir);
924
+ if (locked?.agents?.length || configuredAgents?.length) {
925
+ const agents = locked?.agents?.length
926
+ ? normalizeAgents(locked.agents)
927
+ : (configuredAgents ?? normalizeAgents());
928
+ const linkPaths = getProjectResourcePaths(projectDir, type, name, agents);
929
+ const existingLinkPath = await this.findFirstExistingPath(linkPaths);
930
+ if (existingLinkPath) {
931
+ return {
932
+ resourcePath: existingLinkPath,
933
+ linkPaths,
934
+ agents,
935
+ mode: this.resolveInstallMode(locked?.mode ?? (await this.readPathMode(existingLinkPath))),
936
+ };
937
+ }
938
+ }
939
+ const existingCandidates = await this.findExistingAgentPaths(projectDir, type, name, "project");
940
+ if (existingCandidates.length === 0) {
941
+ return undefined;
942
+ }
943
+ const existingAgents = normalizeAgents(existingCandidates.map((candidate) => candidate.agent));
944
+ const linkPaths = getProjectResourcePaths(projectDir, type, name, existingAgents);
945
+ return {
946
+ resourcePath: existingCandidates[0].path,
947
+ linkPaths,
948
+ agents: existingAgents,
949
+ mode: await this.readPathMode(existingCandidates[0].path),
950
+ };
951
+ }
952
+ async tryResolveGlobalResourceTarget(projectDir, type, name) {
953
+ const existingCandidates = await this.findExistingAgentPaths(projectDir, type, name, "global");
954
+ if (existingCandidates.length === 0) {
955
+ return undefined;
956
+ }
957
+ const agents = normalizeAgents(existingCandidates.map((candidate) => candidate.agent));
958
+ const linkPaths = getGlobalResourcePaths(this.paths.getHomeDir(), type, name, agents);
959
+ return {
960
+ resourcePath: existingCandidates[0].path,
961
+ linkPaths,
962
+ agents,
963
+ mode: await this.readPathMode(existingCandidates[0].path),
964
+ };
965
+ }
966
+ async findExistingAgentPaths(projectDir, type, name, scope) {
967
+ const rootDir = scope === "global" ? this.paths.getHomeDir() : projectDir;
968
+ const candidates = getSupportedAgentNames().map((agent) => ({
969
+ agent,
970
+ path: scope === "global"
971
+ ? getGlobalResourcePaths(rootDir, type, name, [agent])[0]
972
+ : getProjectResourcePaths(rootDir, type, name, [agent])[0],
973
+ }));
974
+ const existingCandidates = [];
975
+ for (const candidate of candidates) {
976
+ if (await this.exists(candidate.path))
977
+ existingCandidates.push(candidate);
978
+ }
979
+ return existingCandidates;
980
+ }
981
+ async findFirstExistingPath(paths) {
982
+ for (const targetPath of paths) {
983
+ if (await this.exists(targetPath))
984
+ return targetPath;
985
+ }
986
+ return undefined;
987
+ }
988
+ async readPathMode(targetPath) {
989
+ const stat = await fs.lstat(targetPath);
990
+ return stat.isSymbolicLink() ? "link" : "copy";
991
+ }
436
992
  async resolveInstalledResource(projectDir, type, name) {
437
993
  const locked = await this.getLockedResource(projectDir, type, name);
438
994
  const configuredAgents = await this.getConfiguredAgents(projectDir);
@@ -546,6 +1102,48 @@ export class ServiceFactory {
546
1102
  this.readStringArrayMetadata(metadata, "targets"),
547
1103
  };
548
1104
  }
1105
+ async updateRenamedResourceMetadata(resourceDir, type, oldName, newName) {
1106
+ const yamlPath = path.join(resourceDir, "himan.yaml");
1107
+ if (await this.exists(yamlPath)) {
1108
+ const raw = await fs.readFile(yamlPath, "utf8");
1109
+ const parsed = YAML.parse(raw);
1110
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1111
+ return;
1112
+ }
1113
+ await fs.writeFile(yamlPath, YAML.stringify({
1114
+ ...parsed,
1115
+ name: newName,
1116
+ }), "utf8");
1117
+ return;
1118
+ }
1119
+ if (type !== "skill")
1120
+ return;
1121
+ const entryPath = path.join(resourceDir, this.getDefaultEntry(type));
1122
+ if (!(await this.exists(entryPath)))
1123
+ return;
1124
+ const raw = await fs.readFile(entryPath, "utf8");
1125
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
1126
+ if (!match)
1127
+ return;
1128
+ let parsed;
1129
+ try {
1130
+ parsed = YAML.parse(match[1]);
1131
+ }
1132
+ catch {
1133
+ return;
1134
+ }
1135
+ if (typeof parsed !== "object" ||
1136
+ parsed === null ||
1137
+ Array.isArray(parsed) ||
1138
+ parsed.name !== oldName) {
1139
+ return;
1140
+ }
1141
+ const frontMatter = YAML.stringify({
1142
+ ...parsed,
1143
+ name: newName,
1144
+ }).trimEnd();
1145
+ await fs.writeFile(entryPath, `---\n${frontMatter}\n---\n${raw.slice(match[0].length)}`, "utf8");
1146
+ }
549
1147
  async readFrontMatter(filePath) {
550
1148
  const raw = await fs.readFile(filePath, "utf8");
551
1149
  const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw.trimStart());
@@ -571,6 +1169,13 @@ export class ServiceFactory {
571
1169
  .filter(Boolean);
572
1170
  return items.length > 0 ? items : undefined;
573
1171
  }
1172
+ errorCheck(name, message, error) {
1173
+ return {
1174
+ name,
1175
+ status: "error",
1176
+ message: `${message} ${error instanceof Error ? error.message : String(error)}`,
1177
+ };
1178
+ }
574
1179
  async exists(targetPath) {
575
1180
  try {
576
1181
  await fs.access(targetPath);
@@ -585,6 +1190,10 @@ export class ServiceFactory {
585
1190
  if (await this.exists(devPath)) {
586
1191
  return devPath;
587
1192
  }
1193
+ const projectTarget = await this.tryResolveProjectResourceTarget(projectDir, type, name);
1194
+ if (projectTarget) {
1195
+ return projectTarget.resourcePath;
1196
+ }
588
1197
  const repoResourceDir = await this.getRepoResourceDir(type, name);
589
1198
  if (await this.exists(repoResourceDir)) {
590
1199
  return repoResourceDir;
@@ -612,6 +1221,15 @@ export class ServiceFactory {
612
1221
  getDefaultEntry(type) {
613
1222
  return type === "skill" ? "SKILL.md" : "content.md";
614
1223
  }
1224
+ getDefaultContent(type, name) {
1225
+ if (type === "rule") {
1226
+ return `# ${name}\n\nDescribe rule instructions here.\n`;
1227
+ }
1228
+ if (type === "command") {
1229
+ return `# ${name}\n\nDescribe command behavior here.\n`;
1230
+ }
1231
+ return `# ${name}\n\nDescribe skill workflow here.\n`;
1232
+ }
615
1233
  validateCreateInput(type, name, options) {
616
1234
  if (!["rule", "command", "skill"].includes(type)) {
617
1235
  throw new HimanError(errorCodes.UNSUPPORTED_RESOURCE_TYPE, `Unsupported resource type for create: ${type}`);
@@ -623,4 +1241,15 @@ export class ServiceFactory {
623
1241
  throw new HimanError(errorCodes.TEMPLATE_NOT_FOUND, `Template not found: ${options.template}`);
624
1242
  }
625
1243
  }
1244
+ validateRenameInput(type, oldName, newName) {
1245
+ if (!["rule", "command", "skill"].includes(type)) {
1246
+ throw new HimanError(errorCodes.UNSUPPORTED_RESOURCE_TYPE, `Unsupported resource type for rename: ${type}`);
1247
+ }
1248
+ if (oldName === newName) {
1249
+ throw new HimanError(errorCodes.INVALID_INPUT, "Old and new resource names must be different.");
1250
+ }
1251
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(newName)) {
1252
+ throw new HimanError(errorCodes.INVALID_RESOURCE_NAME, `Invalid resource name: ${newName}. Use kebab-case only.`);
1253
+ }
1254
+ }
626
1255
  }