@hi-man/himan 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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();
@@ -176,6 +180,46 @@ export class ServiceFactory {
176
180
  supported: getSupportedAgentNames(),
177
181
  };
178
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.checkProjectArchiveStatus(lockCheck.lock));
217
+ checks.push(await this.checkProjectTargets(projectDir, lockCheck.lock));
218
+ return {
219
+ ok: !checks.some((check) => check.status === "error"),
220
+ checks,
221
+ };
222
+ }
179
223
  async clearAgents(scope, projectDir) {
180
224
  if (scope === "project") {
181
225
  await this.projectConfigStore.clearAgents(projectDir);
@@ -188,14 +232,231 @@ export class ServiceFactory {
188
232
  });
189
233
  return { scope };
190
234
  }
191
- async list(type, agents) {
235
+ checkNodeVersion() {
236
+ const version = process.versions.node;
237
+ const major = Number(version.split(".")[0]);
238
+ if (major === 22) {
239
+ return {
240
+ name: "node",
241
+ status: "ok",
242
+ message: `Node.js ${version}.`,
243
+ };
244
+ }
245
+ return {
246
+ name: "node",
247
+ status: "error",
248
+ message: `Node.js ${version} is unsupported. Use Node.js 22.x.`,
249
+ };
250
+ }
251
+ async checkGit() {
252
+ try {
253
+ const result = await execFileAsync("git", ["--version"]);
254
+ return {
255
+ name: "git",
256
+ status: "ok",
257
+ message: result.stdout.trim() || "Git is available.",
258
+ };
259
+ }
260
+ catch (error) {
261
+ return this.errorCheck("git", "Git is not available on PATH.", error);
262
+ }
263
+ }
264
+ async checkHomeState() {
265
+ const root = this.paths.getHimanRoot();
266
+ const reposDir = this.paths.getReposDir();
267
+ const storeDir = this.paths.getStoreDir();
268
+ const missing = [];
269
+ if (!(await this.exists(root)))
270
+ missing.push(root);
271
+ if (!(await this.exists(reposDir)))
272
+ missing.push(reposDir);
273
+ if (!(await this.exists(storeDir)))
274
+ missing.push(storeDir);
275
+ if (missing.length > 0) {
276
+ return {
277
+ name: "home",
278
+ status: "warn",
279
+ message: "Himan home directories are not fully initialized yet.",
280
+ details: { root, reposDir, storeDir, missing },
281
+ };
282
+ }
283
+ return {
284
+ name: "home",
285
+ status: "ok",
286
+ message: `Himan home is initialized at ${root}.`,
287
+ details: { root, reposDir, storeDir },
288
+ };
289
+ }
290
+ async checkSourceResources() {
291
+ try {
292
+ const source = await this.loadSourceFromConfig();
293
+ const entries = await Promise.all(RESOURCE_TYPES.map(async (type) => [type, await source.list(type)]));
294
+ const counts = Object.fromEntries(entries.map(([type, resources]) => [type, resources.length]));
295
+ const total = RESOURCE_TYPES.reduce((sum, type) => sum + counts[type], 0);
296
+ return {
297
+ name: "resources",
298
+ status: "ok",
299
+ message: `Scanned ${total} resources from current source.`,
300
+ details: { counts },
301
+ };
302
+ }
303
+ catch (error) {
304
+ return this.errorCheck("resources", "Cannot scan current source resources.", error);
305
+ }
306
+ }
307
+ async checkAgents(projectDir) {
308
+ try {
309
+ const settings = await this.getAgentSettings(projectDir);
310
+ const scope = settings.project ? "project" : settings.global ? "global" : "default";
311
+ return {
312
+ name: "agents",
313
+ status: "ok",
314
+ message: `Effective agents: ${settings.effective.join(", ")} (${scope}).`,
315
+ details: settings,
316
+ };
317
+ }
318
+ catch (error) {
319
+ return this.errorCheck("agents", "Cannot resolve effective agents.", error);
320
+ }
321
+ }
322
+ async checkProjectLock(projectDir) {
323
+ const lockPath = this.lockStore.getLockPath(projectDir);
324
+ const { lock, state } = await this.lockStore.loadWithState(projectDir);
325
+ if (state === "missing") {
326
+ return {
327
+ check: {
328
+ name: "lock",
329
+ status: "ok",
330
+ message: "No himan.lock found for this project yet.",
331
+ details: { lockPath },
332
+ },
333
+ };
334
+ }
335
+ if (state === "invalid" || !lock) {
336
+ return {
337
+ check: {
338
+ name: "lock",
339
+ status: "error",
340
+ message: `Lock file is invalid: ${lockPath}`,
341
+ details: { lockPath },
342
+ },
343
+ };
344
+ }
345
+ if (lock.resources.length === 0) {
346
+ return {
347
+ check: {
348
+ name: "lock",
349
+ status: "warn",
350
+ message: "himan.lock has no resources.",
351
+ details: { lockPath },
352
+ },
353
+ lock,
354
+ };
355
+ }
356
+ return {
357
+ check: {
358
+ name: "lock",
359
+ status: "ok",
360
+ message: `himan.lock tracks ${lock.resources.length} resources.`,
361
+ details: { lockPath, source: lock.source },
362
+ },
363
+ lock,
364
+ };
365
+ }
366
+ async checkProjectArchiveStatus(lock) {
367
+ if (!lock || lock.resources.length === 0) {
368
+ return {
369
+ name: "archive",
370
+ status: "ok",
371
+ message: "No locked resources to check for archive status.",
372
+ };
373
+ }
374
+ try {
375
+ const source = await this.loadSourceFromLock(this.normalizeLockSourceInfo(lock.source));
376
+ const archived = [];
377
+ for (const resource of lock.resources) {
378
+ if (await source.isArchived(resource.type, resource.name)) {
379
+ archived.push(`${resource.type}/${resource.name}@${resource.version}`);
380
+ }
381
+ }
382
+ if (archived.length > 0) {
383
+ return {
384
+ name: "archive",
385
+ status: "warn",
386
+ message: `${archived.length} locked resources are archived in the current source.`,
387
+ details: { resources: archived },
388
+ };
389
+ }
390
+ return {
391
+ name: "archive",
392
+ status: "ok",
393
+ message: "No locked resources are archived in the current source.",
394
+ };
395
+ }
396
+ catch (error) {
397
+ return {
398
+ name: "archive",
399
+ status: "warn",
400
+ message: `Cannot check archived resources. ${error instanceof Error ? error.message : String(error)}`,
401
+ };
402
+ }
403
+ }
404
+ async checkProjectTargets(projectDir, lock) {
405
+ if (!lock || lock.resources.length === 0) {
406
+ return {
407
+ name: "targets",
408
+ status: "ok",
409
+ message: "No locked project targets to verify.",
410
+ };
411
+ }
412
+ const missing = [];
413
+ for (const resource of lock.resources) {
414
+ const agents = normalizeAgents(resource.agents);
415
+ const targets = getProjectResourcePaths(projectDir, resource.type, resource.name, agents);
416
+ for (const targetPath of targets) {
417
+ if (!(await this.exists(targetPath))) {
418
+ missing.push({
419
+ resource: `${resource.type}/${resource.name}@${resource.version}`,
420
+ path: targetPath,
421
+ });
422
+ }
423
+ }
424
+ }
425
+ if (missing.length > 0) {
426
+ return {
427
+ name: "targets",
428
+ status: "warn",
429
+ message: `Missing ${missing.length} installed targets. Run \`himan install\` to restore them.`,
430
+ details: { missing },
431
+ };
432
+ }
433
+ return {
434
+ name: "targets",
435
+ status: "ok",
436
+ message: "All locked project targets exist.",
437
+ };
438
+ }
439
+ async list(type, agents, options = {}) {
192
440
  const source = await this.loadSourceFromConfig();
193
- const resources = await source.list(type);
441
+ const resources = await source.list(type, options);
194
442
  if (!agents?.length)
195
443
  return resources;
196
444
  const selected = normalizeAgents(agents);
197
445
  return resources.filter((resource) => normalizeAgents(resource.agents).some((agent) => selected.includes(agent)));
198
446
  }
447
+ async archive(type, name, options = {}) {
448
+ this.validateResourceIdentity(type, name, "archive");
449
+ const source = await this.loadSourceFromConfig();
450
+ return source.archive(type, name, {
451
+ ...options,
452
+ reason: options.reason?.trim(),
453
+ });
454
+ }
455
+ async restore(type, name, options = {}) {
456
+ this.validateResourceIdentity(type, name, "restore");
457
+ const source = await this.loadSourceFromConfig();
458
+ return source.restore(type, name, options);
459
+ }
199
460
  async listInstalled(projectDir, type, agents) {
200
461
  const { lock, state } = await this.lockStore.loadWithState(projectDir);
201
462
  if (state === "invalid") {
@@ -222,31 +483,42 @@ export class ServiceFactory {
222
483
  const source = await this.loadSourceFromConfig();
223
484
  return source.history(type, name);
224
485
  }
225
- async install(type, name, version, projectDir, agents, mode = "copy") {
486
+ async install(type, name, version, projectDir, agents, mode = "copy", options = {}) {
226
487
  const { source, sourceInfo } = await this.loadSourceWithInfoFromConfig();
227
- return this.installWithSource(source, sourceInfo, type, name, version, projectDir, agents, mode);
488
+ return this.installWithSource(source, sourceInfo, type, name, version, projectDir, agents, mode, "project", options);
228
489
  }
229
- async installGlobal(type, name, version, projectDir, agents, mode = "copy") {
490
+ async installGlobal(type, name, version, projectDir, agents, mode = "copy", options = {}) {
230
491
  const source = await this.loadSourceFromConfig();
231
- return this.installWithSource(source, undefined, type, name, version, projectDir, agents, mode, "global");
492
+ return this.installWithSource(source, undefined, type, name, version, projectDir, agents, mode, "global", options);
232
493
  }
233
494
  async dev(type, name, projectDir) {
234
- const installInfo = await this.resolveInstalledResource(projectDir, type, name);
235
- const installedPath = installInfo.installedPath;
236
- const devPath = this.getProjectDevPath(projectDir, type, name);
237
- if (!(await this.exists(devPath))) {
238
- await fs.mkdir(path.dirname(devPath), { recursive: true });
239
- await fs.cp(installedPath, devPath, { recursive: true });
495
+ const projectTarget = await this.tryResolveProjectResourceTarget(projectDir, type, name);
496
+ if (projectTarget) {
497
+ return {
498
+ type,
499
+ name,
500
+ devPath: projectTarget.resourcePath,
501
+ linkPath: projectTarget.linkPaths[0],
502
+ mode: projectTarget.mode,
503
+ sourceScope: "project",
504
+ };
240
505
  }
241
- for (const linkPath of installInfo.linkPaths) {
242
- await this.materializeResource(devPath, linkPath, installInfo.mode);
506
+ const globalTarget = await this.tryResolveGlobalResourceTarget(projectDir, type, name);
507
+ if (!globalTarget) {
508
+ throw new HimanError(errorCodes.INSTALL_NOT_FOUND, `Installed resource link not found for ${type}/${name}. Run install first.`);
509
+ }
510
+ const projectLinkPaths = getProjectResourcePaths(projectDir, type, name, globalTarget.agents);
511
+ for (const [index, linkPath] of projectLinkPaths.entries()) {
512
+ const sourcePath = globalTarget.linkPaths[index] ?? globalTarget.resourcePath;
513
+ await this.materializeResource(sourcePath, linkPath, "copy");
243
514
  }
244
515
  return {
245
516
  type,
246
517
  name,
247
- devPath,
248
- linkPath: installInfo.linkPaths[0],
249
- mode: installInfo.mode,
518
+ devPath: projectLinkPaths[0],
519
+ linkPath: projectLinkPaths[0],
520
+ mode: "copy",
521
+ sourceScope: "global",
250
522
  };
251
523
  }
252
524
  async uninstall(type, name, projectDir) {
@@ -260,58 +532,120 @@ export class ServiceFactory {
260
532
  await this.lockStore.removeResource(projectDir, { type, name });
261
533
  return { type, name, linkPath: installInfo.linkPaths[0] };
262
534
  }
263
- async publish(type, name, releaseType, projectDir) {
535
+ async publish(type, name, releaseType, projectDir, options = {}) {
536
+ const installScope = options.installScope ?? "project";
537
+ this.reportPublishProgress(options, "prepare", `Preparing ${type}/${name}.`);
264
538
  const source = await this.loadSourceFromConfig();
539
+ if (await source.isArchived(type, name)) {
540
+ throw new HimanError(errorCodes.RESOURCE_ARCHIVED, `Resource is archived: ${type}/${name}. Restore it before publishing a new version.`);
541
+ }
265
542
  const sourceDir = await this.resolvePublishSourceDir(type, name, projectDir);
266
543
  const existingInstallInfo = await this.tryResolveInstalledResource(projectDir, type, name);
544
+ const existingGlobalInstallInfo = await this.tryResolveGlobalResourceTarget(projectDir, type, name);
267
545
  const history = await source.history(type, name);
268
546
  const latest = history[0]?.version ?? "0.0.0";
269
547
  const nextVersion = this.versions.nextVersion(latest, releaseType);
548
+ this.reportPublishProgress(options, "resolve-version", `Resolved ${releaseType} version ${nextVersion}.`);
549
+ this.reportPublishProgress(options, "publish-source", `Publishing ${type}/${name}@${nextVersion} to the Git source.`);
270
550
  const result = await source.publish(type, name, nextVersion, sourceDir, {
271
551
  releaseType,
272
552
  });
273
553
  const storePath = this.getStorePath(type, name, nextVersion);
554
+ this.reportPublishProgress(options, "sync-store", `Syncing ${type}/${name}@${nextVersion} into the local store.`);
274
555
  if (!(await this.exists(storePath))) {
275
556
  await source.pull(type, name, nextVersion, storePath);
276
557
  }
277
558
  const locked = await this.getLockedResource(projectDir, type, name);
278
559
  const resourceMeta = await this.readResourceMetaFromDir(storePath, type);
279
560
  const configuredAgents = await this.getConfiguredAgents(projectDir);
280
- const nextAgents = locked?.agents?.length
281
- ? normalizeAgents(locked.agents)
282
- : existingInstallInfo?.agents.length
283
- ? normalizeAgents(existingInstallInfo.agents)
284
- : configuredAgents ?? normalizeAgents(resourceMeta?.agents);
285
561
  const installMode = "copy";
286
- const linkPaths = getProjectResourcePaths(projectDir, type, name, nextAgents);
562
+ const nextAgents = installScope === "global"
563
+ ? existingGlobalInstallInfo?.agents.length
564
+ ? normalizeAgents(existingGlobalInstallInfo.agents)
565
+ : existingInstallInfo?.agents.length
566
+ ? normalizeAgents(existingInstallInfo.agents)
567
+ : locked?.agents?.length
568
+ ? normalizeAgents(locked.agents)
569
+ : configuredAgents ?? normalizeAgents(resourceMeta?.agents)
570
+ : locked?.agents?.length
571
+ ? normalizeAgents(locked.agents)
572
+ : existingInstallInfo?.agents.length
573
+ ? normalizeAgents(existingInstallInfo.agents)
574
+ : configuredAgents ?? normalizeAgents(resourceMeta?.agents);
575
+ const linkPaths = installScope === "global"
576
+ ? getGlobalResourcePaths(this.paths.getHomeDir(), type, name, nextAgents)
577
+ : getProjectResourcePaths(projectDir, type, name, nextAgents);
578
+ this.reportPublishProgress(options, "install", installScope === "global"
579
+ ? `Installing published version globally for ${nextAgents.join(", ")}.`
580
+ : `Installing published version into the current project for ${nextAgents.join(", ")}.`);
287
581
  for (const linkPath of linkPaths) {
288
582
  await this.materializeResource(storePath, linkPath, installMode);
289
583
  }
290
- const sourceInfo = await this.getLockSourceInfo();
291
- await this.lockStore.upsertResource(projectDir, sourceInfo, {
292
- type,
293
- name,
294
- version: nextVersion,
295
- agents: nextAgents,
296
- mode: installMode,
297
- });
584
+ if (installScope === "project") {
585
+ const sourceInfo = await this.getLockSourceInfo();
586
+ await this.lockStore.upsertResource(projectDir, sourceInfo, {
587
+ type,
588
+ name,
589
+ version: nextVersion,
590
+ agents: nextAgents,
591
+ mode: installMode,
592
+ });
593
+ }
594
+ this.reportPublishProgress(options, "cleanup", `Cleaning up legacy dev copy if present.`);
298
595
  await fs.rm(this.getProjectDevPath(projectDir, type, name), {
299
596
  recursive: true,
300
597
  force: true,
301
598
  });
302
- return { type, name, version: result.version, tag: result.tag };
599
+ this.reportPublishProgress(options, "done", `Published ${type}/${name}@${result.version}.`);
600
+ return {
601
+ type,
602
+ name,
603
+ version: result.version,
604
+ tag: result.tag,
605
+ installScope,
606
+ linkPath: linkPaths[0],
607
+ };
303
608
  }
304
609
  async create(type, name, options, projectDir) {
305
610
  this.validateCreateInput(type, name, options);
306
- const source = await this.loadSourceFromConfig();
307
- return source.create(type, name, {
308
- description: options.description,
309
- agents: await this.resolveEffectiveAgents(projectDir, options.agents),
310
- entry: options.entry,
311
- template: options.template ?? "basic",
312
- force: options.force,
313
- dryRun: options.dryRun,
314
- });
611
+ await this.loadSourceFromConfig();
612
+ const agents = await this.resolveEffectiveAgents(projectDir, options.agents);
613
+ const resourcePaths = getProjectResourcePaths(projectDir, type, name, agents);
614
+ const entry = options.entry ?? this.getDefaultEntry(type);
615
+ const files = resourcePaths.flatMap((resourcePath) => [
616
+ path.join(resourcePath, "himan.yaml"),
617
+ path.join(resourcePath, entry),
618
+ ]);
619
+ const existingPaths = [];
620
+ for (const resourcePath of resourcePaths) {
621
+ if (await this.exists(resourcePath))
622
+ existingPaths.push(resourcePath);
623
+ }
624
+ if (existingPaths.length > 0 && !options.force) {
625
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, `Resource already exists: ${type}/${name}`, { paths: existingPaths });
626
+ }
627
+ if (!options.dryRun) {
628
+ for (const resourcePath of resourcePaths) {
629
+ await fs.rm(resourcePath, { recursive: true, force: true });
630
+ await fs.mkdir(resourcePath, { recursive: true });
631
+ await fs.writeFile(path.join(resourcePath, "himan.yaml"), YAML.stringify({
632
+ name,
633
+ type,
634
+ version: "0.1.0",
635
+ entry,
636
+ description: options.description ?? `${type} resource ${name}`,
637
+ agents,
638
+ }), "utf8");
639
+ await fs.writeFile(path.join(resourcePath, entry), this.getDefaultContent(type, name), "utf8");
640
+ }
641
+ }
642
+ return {
643
+ type,
644
+ name,
645
+ resourceDir: resourcePaths[0],
646
+ files,
647
+ dryRun: Boolean(options.dryRun),
648
+ };
315
649
  }
316
650
  async rename(type, oldName, newName, projectDir, options = {}) {
317
651
  this.validateRenameInput(type, oldName, newName);
@@ -353,12 +687,16 @@ export class ServiceFactory {
353
687
  const lockSourceInfo = this.normalizeLockSourceInfo(lock.source);
354
688
  const lockedSource = await this.loadSourceFromLock(lockSourceInfo);
355
689
  for (const item of lock.resources) {
356
- const result = await this.installWithSource(lockedSource, lockSourceInfo, item.type, item.name, item.version, projectDir, agents ?? item.agents, mode ?? this.resolveInstallMode(item.mode), "project");
690
+ const result = await this.installWithSource(lockedSource, lockSourceInfo, item.type, item.name, item.version, projectDir, agents ?? item.agents, mode ?? this.resolveInstallMode(item.mode), "project", { includeArchived: true });
357
691
  results.push(result);
358
692
  }
359
693
  return results;
360
694
  }
361
- async installWithSource(source, sourceInfo, type, name, version, projectDir, agents, mode, scope = "project") {
695
+ async installWithSource(source, sourceInfo, type, name, version, projectDir, agents, mode, scope = "project", options = {}) {
696
+ const archived = await source.isArchived(type, name);
697
+ if (archived && !options.includeArchived) {
698
+ throw new HimanError(errorCodes.RESOURCE_ARCHIVED, `Resource is archived: ${type}/${name}. Use --include-archived to install an archived version explicitly.`);
699
+ }
362
700
  const history = await source.history(type, name);
363
701
  if (history.length === 0) {
364
702
  throw new HimanError(errorCodes.RESOURCE_NOT_FOUND, `Resource not found: ${type}/${name}`);
@@ -636,6 +974,80 @@ export class ServiceFactory {
636
974
  resolveInstallMode(mode) {
637
975
  return mode === "link" ? "link" : "copy";
638
976
  }
977
+ reportPublishProgress(options, stage, message) {
978
+ options.onProgress?.({ stage, message });
979
+ }
980
+ async tryResolveProjectResourceTarget(projectDir, type, name) {
981
+ const locked = await this.getLockedResource(projectDir, type, name);
982
+ const configuredAgents = await this.getConfiguredAgents(projectDir);
983
+ if (locked?.agents?.length || configuredAgents?.length) {
984
+ const agents = locked?.agents?.length
985
+ ? normalizeAgents(locked.agents)
986
+ : (configuredAgents ?? normalizeAgents());
987
+ const linkPaths = getProjectResourcePaths(projectDir, type, name, agents);
988
+ const existingLinkPath = await this.findFirstExistingPath(linkPaths);
989
+ if (existingLinkPath) {
990
+ return {
991
+ resourcePath: existingLinkPath,
992
+ linkPaths,
993
+ agents,
994
+ mode: this.resolveInstallMode(locked?.mode ?? (await this.readPathMode(existingLinkPath))),
995
+ };
996
+ }
997
+ }
998
+ const existingCandidates = await this.findExistingAgentPaths(projectDir, type, name, "project");
999
+ if (existingCandidates.length === 0) {
1000
+ return undefined;
1001
+ }
1002
+ const existingAgents = normalizeAgents(existingCandidates.map((candidate) => candidate.agent));
1003
+ const linkPaths = getProjectResourcePaths(projectDir, type, name, existingAgents);
1004
+ return {
1005
+ resourcePath: existingCandidates[0].path,
1006
+ linkPaths,
1007
+ agents: existingAgents,
1008
+ mode: await this.readPathMode(existingCandidates[0].path),
1009
+ };
1010
+ }
1011
+ async tryResolveGlobalResourceTarget(projectDir, type, name) {
1012
+ const existingCandidates = await this.findExistingAgentPaths(projectDir, type, name, "global");
1013
+ if (existingCandidates.length === 0) {
1014
+ return undefined;
1015
+ }
1016
+ const agents = normalizeAgents(existingCandidates.map((candidate) => candidate.agent));
1017
+ const linkPaths = getGlobalResourcePaths(this.paths.getHomeDir(), type, name, agents);
1018
+ return {
1019
+ resourcePath: existingCandidates[0].path,
1020
+ linkPaths,
1021
+ agents,
1022
+ mode: await this.readPathMode(existingCandidates[0].path),
1023
+ };
1024
+ }
1025
+ async findExistingAgentPaths(projectDir, type, name, scope) {
1026
+ const rootDir = scope === "global" ? this.paths.getHomeDir() : projectDir;
1027
+ const candidates = getSupportedAgentNames().map((agent) => ({
1028
+ agent,
1029
+ path: scope === "global"
1030
+ ? getGlobalResourcePaths(rootDir, type, name, [agent])[0]
1031
+ : getProjectResourcePaths(rootDir, type, name, [agent])[0],
1032
+ }));
1033
+ const existingCandidates = [];
1034
+ for (const candidate of candidates) {
1035
+ if (await this.exists(candidate.path))
1036
+ existingCandidates.push(candidate);
1037
+ }
1038
+ return existingCandidates;
1039
+ }
1040
+ async findFirstExistingPath(paths) {
1041
+ for (const targetPath of paths) {
1042
+ if (await this.exists(targetPath))
1043
+ return targetPath;
1044
+ }
1045
+ return undefined;
1046
+ }
1047
+ async readPathMode(targetPath) {
1048
+ const stat = await fs.lstat(targetPath);
1049
+ return stat.isSymbolicLink() ? "link" : "copy";
1050
+ }
639
1051
  async resolveInstalledResource(projectDir, type, name) {
640
1052
  const locked = await this.getLockedResource(projectDir, type, name);
641
1053
  const configuredAgents = await this.getConfiguredAgents(projectDir);
@@ -816,6 +1228,13 @@ export class ServiceFactory {
816
1228
  .filter(Boolean);
817
1229
  return items.length > 0 ? items : undefined;
818
1230
  }
1231
+ errorCheck(name, message, error) {
1232
+ return {
1233
+ name,
1234
+ status: "error",
1235
+ message: `${message} ${error instanceof Error ? error.message : String(error)}`,
1236
+ };
1237
+ }
819
1238
  async exists(targetPath) {
820
1239
  try {
821
1240
  await fs.access(targetPath);
@@ -830,6 +1249,10 @@ export class ServiceFactory {
830
1249
  if (await this.exists(devPath)) {
831
1250
  return devPath;
832
1251
  }
1252
+ const projectTarget = await this.tryResolveProjectResourceTarget(projectDir, type, name);
1253
+ if (projectTarget) {
1254
+ return projectTarget.resourcePath;
1255
+ }
833
1256
  const repoResourceDir = await this.getRepoResourceDir(type, name);
834
1257
  if (await this.exists(repoResourceDir)) {
835
1258
  return repoResourceDir;
@@ -857,6 +1280,15 @@ export class ServiceFactory {
857
1280
  getDefaultEntry(type) {
858
1281
  return type === "skill" ? "SKILL.md" : "content.md";
859
1282
  }
1283
+ getDefaultContent(type, name) {
1284
+ if (type === "rule") {
1285
+ return `# ${name}\n\nDescribe rule instructions here.\n`;
1286
+ }
1287
+ if (type === "command") {
1288
+ return `# ${name}\n\nDescribe command behavior here.\n`;
1289
+ }
1290
+ return `# ${name}\n\nDescribe skill workflow here.\n`;
1291
+ }
860
1292
  validateCreateInput(type, name, options) {
861
1293
  if (!["rule", "command", "skill"].includes(type)) {
862
1294
  throw new HimanError(errorCodes.UNSUPPORTED_RESOURCE_TYPE, `Unsupported resource type for create: ${type}`);
@@ -868,6 +1300,14 @@ export class ServiceFactory {
868
1300
  throw new HimanError(errorCodes.TEMPLATE_NOT_FOUND, `Template not found: ${options.template}`);
869
1301
  }
870
1302
  }
1303
+ validateResourceIdentity(type, name, action) {
1304
+ if (!["rule", "command", "skill"].includes(type)) {
1305
+ throw new HimanError(errorCodes.UNSUPPORTED_RESOURCE_TYPE, `Unsupported resource type for ${action}: ${type}`);
1306
+ }
1307
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
1308
+ throw new HimanError(errorCodes.INVALID_RESOURCE_NAME, `Invalid resource name: ${name}. Use kebab-case only.`);
1309
+ }
1310
+ }
871
1311
  validateRenameInput(type, oldName, newName) {
872
1312
  if (!["rule", "command", "skill"].includes(type)) {
873
1313
  throw new HimanError(errorCodes.UNSUPPORTED_RESOURCE_TYPE, `Unsupported resource type for rename: ${type}`);
@@ -13,6 +13,7 @@ export const errorCodes = {
13
13
  NOT_IMPLEMENTED: "E_NOT_IMPLEMENTED",
14
14
  INVALID_INPUT: "E_INVALID_INPUT",
15
15
  RESOURCE_NOT_FOUND: "E_RESOURCE_NOT_FOUND",
16
+ RESOURCE_ARCHIVED: "E_RESOURCE_ARCHIVED",
16
17
  VERSION_NOT_FOUND: "E_VERSION_NOT_FOUND",
17
18
  INSTALL_NOT_FOUND: "E_INSTALL_NOT_FOUND",
18
19
  LOCK_NOT_FOUND: "E_LOCK_NOT_FOUND",
@@ -58,6 +58,12 @@
58
58
  - **常见触发**:安装不存在的资源、切换到不存在的 source 名称、发布目标资源不存在。
59
59
  - **建议处理**:先执行 `himan list <type>` / `himan source list` 确认名称。
60
60
 
61
+ ### `E_RESOURCE_ARCHIVED`
62
+
63
+ - **含义**:资源已归档,默认不允许作为 active 资源继续使用。
64
+ - **常见触发**:直接安装或发布已归档资源,或重复归档已经在 `archive/<plural>/<name>` 下的资源。
65
+ - **建议处理**:如需继续维护,先执行 `himan resource restore <type> <name>`;如只需安装历史版本,显式传 `--include-archived`。
66
+
61
67
  ### `E_VERSION_NOT_FOUND`
62
68
 
63
69
  - **含义**:指定版本不存在。