@hi-man/himan 0.3.4 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ The format is based on Keep a Changelog, and this project follows semver for the
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-05-13
10
+
11
+ ### Added
12
+
13
+ - Added the `github-npm-publish` skill for reusable GitHub Actions npm release workflow guidance.
14
+ - Added `himan resource rename` and top-level `himan rename`, currently marked not recommended, to rename source resources, update metadata/docs, preserve old tags, create a latest-version tag for the new name, and migrate the current project's install targets and lock entry by default.
15
+ - Added `himan source clone` and `himan source sync` for cloning Git sources and syncing latest resource snapshots into another Git source.
16
+ - Added static `analysis` metadata to newly scaffolded skill `himan.yaml` files and a `himan-skill-metadata` skill for generating matching metadata when agents create skills.
17
+
18
+ ## [0.3.5] - 2026-05-12
19
+
20
+ ### Changed
21
+
22
+ - Changed the CLI version shortcut from `-V` to `-v`; `--version` remains supported.
23
+ - Changed the default install mode to `copy`; pass `--mode link` to install resources as symlinks.
24
+
9
25
  ## [0.3.4] - 2026-05-11
10
26
 
11
27
  ### Changed
package/README.md CHANGED
@@ -50,7 +50,7 @@ himan dev rule my-rule
50
50
  himan publish rule my-rule --patch
51
51
  ```
52
52
 
53
- - **rule / command / skill**:都支持 `create`、`list`、`history`、`install`、`dev`、`publish`、`uninstall`。
53
+ - **rule / command / skill**:都支持 `create`、`rename`、`list`、`history`、`install`、`dev`、`publish`、`uninstall`;其中 `rename` 暂不推荐使用。
54
54
  - 安装后项目目标位置(按 `agents`,默认 `cursor`):
55
55
  - `cursor` -> `.cursor/{rules|commands|skills}/<name>`
56
56
  - `claude-code` -> `.claude/{rules|commands|skills}/<name>`
@@ -66,7 +66,7 @@ himan publish rule my-rule --patch
66
66
  - `command` -> `.himan/dev/command/<name>`
67
67
  - `skill` -> `.himan/dev/skill/<name>`
68
68
  - lock 文件:项目安装 `install <type> <name[@version]>` 会写入 `himan.lock`,记录 source、精确版本、agent 和安装模式;`himan install`(无参数)会按 lock 记录的 source 批量恢复安装,不受当前 default source 切换影响。`--global` 安装不写当前项目的 `himan.lock`。
69
- - 安装模式:默认 `--mode link` 使用软链;也可用 `--mode copy` 将资源复制到目标 agent 目录,lock 会记录并复现该模式。
69
+ - 安装模式:默认 `--mode copy` 将资源复制到目标 agent 目录;也可用 `--mode link` 使用软链,lock 会记录并复现该模式。
70
70
  - 默认 agent:`agent use <agent>` 默认写当前项目 `.himan/config.json`;加 `--global` 写入 `~/.himan/config.json`。当前项目配置优先于全局配置。
71
71
 
72
72
  版本以 Git tag 为准,格式:`rule/my-rule@1.0.0`。更多设计见 [docs/mvp](./docs/mvp/README.md)。
@@ -96,15 +96,45 @@ your-himan-source/
96
96
  - `README.md`:source 仓库入口文档,建议记录资源目录说明、推荐安装方式、默认 agent 策略、常用资源索引和维护约定。
97
97
  - `CHANGELOG.md`:source 仓库级变更记录,建议记录新增、变更、废弃、移除的资源,以及重要版本发布说明。
98
98
  - `rules/`、`commands/`、`skills/`:按资源类型分组;每个子目录是一份 himan 资源。
99
- - `himan.yaml`:可选资源元数据;存在时供 himan 扫描、校验、读取入口和默认 agent
99
+ - `himan.yaml`:可选资源元数据;存在时供 himan 扫描、校验、读取入口和默认 agent;skill 资源可包含 `analysis` 静态分析信息。
100
100
  - `content.md` / `SKILL.md`:资源主入口;没有 `himan.yaml` 时,`rule` / `command` 默认使用 `content.md`,`skill` 默认使用 `SKILL.md`。
101
101
 
102
+ skill 的 `himan.yaml` 推荐包含静态分析 metadata,便于 hooks、日志和后续分析系统关联 skill 内容成本与依赖:
103
+
104
+ ```yaml
105
+ name: my-skill
106
+ type: skill
107
+ version: 0.1.0
108
+ entry: SKILL.md
109
+ description: Do the skill workflow.
110
+ agents:
111
+ - codex
112
+ analysis:
113
+ content:
114
+ tokenizer: approx-char-v1
115
+ tokenEstimator: ceil(chars/4)
116
+ entryTokens: 120
117
+ packageTokens: 180
118
+ contentHash: sha256:...
119
+ measuredAt: "2026-05-13T00:00:00.000Z"
120
+ measuredBy: codex
121
+ dependencies:
122
+ skills: []
123
+ scripts: []
124
+ mcpTools: []
125
+ generation:
126
+ generatedBy: codex
127
+ generatedAt: "2026-05-13T00:00:00.000Z"
128
+ ```
129
+
130
+ `analysis` 是静态构建信息,不记录运行时 token 或执行耗时;`himan create skill` 会为新 skill scaffold 生成基础 `analysis`。
131
+
102
132
  可通过 `himan source init-docs` 为当前 default source 生成根目录文档模板;默认只创建缺失文件,`--force` 会覆盖已有 `README.md` / `CHANGELOG.md`,并把当前 source 中已有的 `rule`、`command`、`skill` 整理进 README 资源索引和 CHANGELOG 初始条目;资源引用会优先带上 Git tag 中的最新 semver 版本;对于尚未补齐 `himan.yaml` 的资源,会按默认入口识别,skill 还会读取 `skills/<name>/SKILL.md` front matter。`--dry-run` 可预览结果。有实际文件变更时,命令会提交并 push 到当前 Git source。
103
133
 
104
- `himan create` 和 `himan publish` 会自动维护 source 根目录文档:
134
+ `himan create`、`himan rename` 和 `himan publish` 会自动维护 source 根目录文档:
105
135
 
106
136
  - `README.md`:只更新 `<!-- himan:resources:start -->` 和 `<!-- himan:resources:end -->` 之间的资源索引;如果没有 marker,会在文件末尾追加一个受控资源索引区。
107
- - `CHANGELOG.md`:向 `[Unreleased]` 下追加资源变更条目;`create` 记录 `Added`,`publish` 记录 `Changed` / published version。
137
+ - `CHANGELOG.md`:向 `[Unreleased]` 下追加资源变更条目;`create` 记录 `Added`,`rename` / `publish` 记录 `Changed`。
108
138
 
109
139
  仓库根目录的 `README.md` 和 `CHANGELOG.md` 不会被安装到 agent 目录;agent 只消费被安装的具体资源目录。当前安装实现会 materialize 资源目录本身,因此对 Cursor 这类要求特定单文件格式的 agent,资源目录内应避免放入会干扰识别的额外文件。
110
140
 
@@ -136,6 +166,7 @@ your-himan-source/
136
166
  | `list [type] [--agent a,b] [--brief] [--installed] [--json]` | 默认列出当前 default source 的资源;未传 `type` 时按 `rule`/`command`/`skill` 分组展示全部资源;可按 agent 过滤;默认显示描述,`--brief` 可隐藏描述;`--installed` 改为查看当前项目 `himan.lock` 中的已安装资源 |
137
167
  | `history <type> <name> [--json]` | 按 tag 查看版本历史 |
138
168
  | `create <type> <name>` | 脚手架;常用选项:`--description`、`--agent a,b`、`--dry-run`、`--force`、`--json` |
169
+ | `rename <type> <old-name> <new-name>` | 暂不推荐使用;重命名当前 default source 中的资源;常用选项:`--dry-run`、`--no-project`、`--json` |
139
170
 
140
171
  ### 3) project(当前项目)
141
172
 
@@ -158,8 +189,8 @@ your-himan-source/
158
189
 
159
190
  也可使用分组命令(与上面等价):
160
191
 
161
- - `himan resource list|history|create ...`
162
- - `himan-resource list|history|create ...`(兼容保留:也可执行 install/dev/uninstall/publish)
192
+ - `himan resource list|history|create|rename ...`
193
+ - `himan-resource list|history|create|rename ...`(兼容保留:也可执行 install/dev/uninstall/publish)
163
194
  - `himan project list|install|dev|uninstall|publish ...`
164
195
  - `himan-project list|install|dev|uninstall|publish ...`
165
196
  - `himan agent list|use|current|clear ...`
@@ -169,9 +200,11 @@ your-himan-source/
169
200
 
170
201
  `publish` 优先使用项目里 `.himan/dev` 对应目录,否则用源仓库里对应目录。若资源目录包含 `himan.yaml`,发布前会校验元数据与入口文件;若没有 `himan.yaml`,则按默认入口推断最小元数据并发布,不会强制创建 `himan.yaml`。若待发布资源内容与最新已发布版本一致,则以 `E_PUBLISH_NO_CHANGES` 终止发布。发布需要可推送的 Git 权限。发布 commit 会包含资源目录以及自动维护的 source 根目录 `README.md` / `CHANGELOG.md`。发布成功后会从新版本 store 以 `copy` 模式重新安装到项目目标、更新 lock,并删除对应 `.himan/dev/<type>/<name>` 开发目录。
171
202
 
203
+ `rename` 暂不推荐使用。该命令会移动 source 仓库里的资源目录并更新资源 metadata 名称、README 资源索引和 CHANGELOG。已有发布 tag 不会被改写;若旧资源已有历史版本,rename 会为新名字创建一个指向当前最新版本的 tag。默认会迁移当前项目中对应的安装目标、`.himan/dev` 副本和 lock 条目;传 `--no-project` 时只改 source。对于 skill,命令只自动更新 metadata / front matter 中的精确 `name` 字段,不会自动替换 `SKILL.md` 正文中的旧名称引用。
204
+
172
205
  `--json` 模式下,失败时会输出机器可读错误 JSON(`stderr`)。错误码定义见 [docs/error-codes.md](./docs/error-codes.md)。
173
206
 
174
- 多源说明:当前是「**多来源可配置,单来源生效**」模型。显式资源命令(`list/install <type> .../history/dev/publish`)作用于当前 default source;`himan install` 无参数恢复时使用 `himan.lock` 中记录的 source。
207
+ 多源说明:当前是「**多来源可配置,单来源生效**」模型。显式资源命令(`list/install <type> .../history/dev/publish/rename`)作用于当前 default source;`himan install` 无参数恢复时使用 `himan.lock` 中记录的 source。
175
208
 
176
209
  ## 当前范围
177
210
 
@@ -1,7 +1,9 @@
1
1
  import { promises as fs } from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { simpleGit } from "simple-git";
4
5
  import { HimanError, errorCodes } from "../../utils/errors.js";
6
+ const MANAGED_TAG_PATTERNS = ["rule/*@*", "command/*@*", "skill/*@*"];
5
7
  export class RepoManager {
6
8
  async cloneOrFetch(repo, targetDir) {
7
9
  const gitDir = path.join(targetDir, ".git");
@@ -61,6 +63,124 @@ export class RepoManager {
61
63
  await this.pushCurrentBranch(git, branch);
62
64
  return true;
63
65
  }
66
+ async cloneManagedSourceRefs(sourceRepoDir, targetRepo, options = {}) {
67
+ const sourceGit = simpleGit(sourceRepoDir);
68
+ await sourceGit.fetch(["--tags", "--prune"]);
69
+ const branch = options.branch ?? (await this.getCurrentBranch(sourceGit));
70
+ const sourceBranchRef = await this.resolveSourceBranchRef(sourceGit, branch);
71
+ const targetBranch = options.targetBranch ?? branch;
72
+ const targetRefs = await this.listRemoteRefs(targetRepo);
73
+ if (targetRefs.length > 0) {
74
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, "Target source repository is not empty.", { targetRepo, refs: targetRefs.map((item) => item.ref) });
75
+ }
76
+ const tags = await this.listManagedResourceTags(sourceRepoDir);
77
+ if (options.dryRun) {
78
+ return {
79
+ branch,
80
+ targetBranch,
81
+ tags,
82
+ dryRun: true,
83
+ pushed: false,
84
+ };
85
+ }
86
+ const refspecs = [
87
+ `${sourceBranchRef}:refs/heads/${targetBranch}`,
88
+ ...tags.map((tag) => `refs/tags/${tag}:refs/tags/${tag}`),
89
+ ];
90
+ await sourceGit.raw(["push", "--atomic", targetRepo, ...refspecs]);
91
+ return {
92
+ branch,
93
+ targetBranch,
94
+ tags,
95
+ dryRun: false,
96
+ pushed: true,
97
+ };
98
+ }
99
+ async syncLatestSourceSnapshot(sourceRepoDir, targetRepo, resources, options = {}) {
100
+ if (resources.length === 0) {
101
+ throw new HimanError(errorCodes.RESOURCE_NOT_FOUND, "No versioned resources found to sync.");
102
+ }
103
+ const targetBranch = options.targetBranch ?? "main";
104
+ const targetRefs = await this.listRemoteRefs(targetRepo);
105
+ const existingTagRefs = new Set(targetRefs
106
+ .filter((item) => item.ref.startsWith("refs/tags/"))
107
+ .map((item) => item.ref.slice("refs/tags/".length)));
108
+ const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "himan-source-sync-"));
109
+ const snapshotsDir = await fs.mkdtemp(path.join(os.tmpdir(), "himan-source-snapshots-"));
110
+ try {
111
+ const targetGit = await this.prepareTargetWorktree(targetRepo, targetDir, targetBranch, targetRefs);
112
+ const results = [];
113
+ for (const resource of resources) {
114
+ const sourceSnapshotDir = path.join(snapshotsDir, resource.type, resource.name);
115
+ await this.materializeSourceResourceSnapshot(sourceRepoDir, resource, sourceSnapshotDir);
116
+ if (existingTagRefs.has(resource.tag)) {
117
+ await this.ensureExistingTagMatchesResource(targetDir, resource, sourceSnapshotDir);
118
+ results.push({
119
+ type: resource.type,
120
+ name: resource.name,
121
+ version: resource.version,
122
+ tag: resource.tag,
123
+ action: "skipped",
124
+ });
125
+ }
126
+ else {
127
+ results.push({
128
+ type: resource.type,
129
+ name: resource.name,
130
+ version: resource.version,
131
+ tag: resource.tag,
132
+ action: "created",
133
+ });
134
+ }
135
+ if (!options.dryRun) {
136
+ const targetResourceDir = path.join(targetDir, this.getResourcePath(resource));
137
+ await fs.rm(targetResourceDir, { recursive: true, force: true });
138
+ await fs.mkdir(path.dirname(targetResourceDir), { recursive: true });
139
+ await fs.cp(sourceSnapshotDir, targetResourceDir, { recursive: true });
140
+ }
141
+ }
142
+ if (options.dryRun) {
143
+ return {
144
+ targetBranch,
145
+ resources: results,
146
+ dryRun: true,
147
+ committed: false,
148
+ pushed: false,
149
+ };
150
+ }
151
+ const changedPaths = [
152
+ ...new Set(resources.map((resource) => `${resource.type}s`)),
153
+ ];
154
+ const committed = await this.commitChanges(targetGit, "sync latest himan source resources", changedPaths, false);
155
+ const createdTags = results
156
+ .filter((result) => result.action === "created")
157
+ .map((result) => result.tag);
158
+ for (const tag of createdTags) {
159
+ await targetGit.addTag(tag);
160
+ }
161
+ const shouldPush = committed || createdTags.length > 0;
162
+ if (shouldPush) {
163
+ await targetGit.raw([
164
+ "push",
165
+ "--atomic",
166
+ "origin",
167
+ `${targetBranch}:refs/heads/${targetBranch}`,
168
+ ...createdTags.map((tag) => `refs/tags/${tag}:refs/tags/${tag}`),
169
+ ]);
170
+ }
171
+ return {
172
+ targetBranch,
173
+ resources: results,
174
+ dryRun: false,
175
+ committed,
176
+ pushed: shouldPush,
177
+ };
178
+ }
179
+ finally {
180
+ await fs.rm(targetDir, { recursive: true, force: true });
181
+ await fs.rm(snapshotsDir, { recursive: true, force: true });
182
+ }
183
+ }
64
184
  async commitChanges(git, message, paths, requireChanges) {
65
185
  const pathspecs = paths.length > 0 ? paths : ["."];
66
186
  await git.add(pathspecs);
@@ -80,6 +200,132 @@ export class RepoManager {
80
200
  await git.commit(message, pathspecs);
81
201
  return true;
82
202
  }
203
+ async prepareTargetWorktree(targetRepo, targetDir, targetBranch, targetRefs) {
204
+ const hasTargetBranch = targetRefs.some((item) => item.ref === `refs/heads/${targetBranch}`);
205
+ if (hasTargetBranch) {
206
+ await simpleGit().clone(targetRepo, targetDir, ["--branch", targetBranch]);
207
+ const git = simpleGit(targetDir);
208
+ await git.fetch(["--tags", "--prune"]);
209
+ return git;
210
+ }
211
+ const git = simpleGit(targetDir);
212
+ await git.raw(["init", `--initial-branch=${targetBranch}`]);
213
+ await git.addRemote("origin", targetRepo);
214
+ if (targetRefs.some((item) => item.ref.startsWith("refs/tags/"))) {
215
+ await git.raw(["fetch", "origin", "+refs/tags/*:refs/tags/*"]);
216
+ }
217
+ return git;
218
+ }
219
+ async materializeSourceResourceSnapshot(sourceRepoDir, resource, targetDir) {
220
+ if (resource.sourceRef) {
221
+ await this.archiveResource(sourceRepoDir, resource.sourceRef, this.getResourcePath(resource), targetDir);
222
+ return;
223
+ }
224
+ if (!resource.sourcePath) {
225
+ throw new HimanError(errorCodes.VERSION_NOT_FOUND, `Version source not found for ${resource.type}/${resource.name}@${resource.version}.`);
226
+ }
227
+ await fs.rm(targetDir, { recursive: true, force: true });
228
+ await fs.mkdir(path.dirname(targetDir), { recursive: true });
229
+ await fs.cp(resource.sourcePath, targetDir, { recursive: true });
230
+ }
231
+ async ensureExistingTagMatchesResource(targetRepoDir, resource, sourceSnapshotDir) {
232
+ const previousDir = await fs.mkdtemp(path.join(os.tmpdir(), "himan-source-tag-"));
233
+ try {
234
+ await this.archiveResource(targetRepoDir, resource.tag, this.getResourcePath(resource), previousDir);
235
+ const [sourceSnapshot, previousSnapshot] = await Promise.all([
236
+ this.readDirectorySnapshot(sourceSnapshotDir),
237
+ this.readDirectorySnapshot(previousDir),
238
+ ]);
239
+ if (!this.snapshotsEqual(sourceSnapshot, previousSnapshot)) {
240
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, `Target tag already exists with different content: ${resource.tag}`);
241
+ }
242
+ }
243
+ finally {
244
+ await fs.rm(previousDir, { recursive: true, force: true });
245
+ }
246
+ }
247
+ async listManagedResourceTags(repoDir) {
248
+ const tags = new Set();
249
+ for (const pattern of MANAGED_TAG_PATTERNS) {
250
+ for (const tag of await this.listTags(repoDir, pattern)) {
251
+ tags.add(tag);
252
+ }
253
+ }
254
+ return [...tags].sort((a, b) => a.localeCompare(b));
255
+ }
256
+ async listRemoteRefs(repo) {
257
+ const output = await simpleGit().raw(["ls-remote", "--heads", "--tags", repo]);
258
+ return output
259
+ .split("\n")
260
+ .map((line) => line.trim())
261
+ .filter(Boolean)
262
+ .map((line) => {
263
+ const [hash, ref] = line.split(/\s+/);
264
+ return { hash, ref };
265
+ })
266
+ .filter((item) => item.hash && item.ref && !item.ref.endsWith("^{}"));
267
+ }
268
+ async getCurrentBranch(git) {
269
+ const branch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
270
+ if (!branch || branch === "HEAD") {
271
+ throw new HimanError(errorCodes.INVALID_INPUT, "Source repository is not on a named branch.");
272
+ }
273
+ return branch;
274
+ }
275
+ async resolveSourceBranchRef(git, branch) {
276
+ if (await this.hasGitRef(git, `refs/heads/${branch}`)) {
277
+ return branch;
278
+ }
279
+ const remoteRef = `refs/remotes/origin/${branch}`;
280
+ if (await this.hasGitRef(git, remoteRef)) {
281
+ return remoteRef;
282
+ }
283
+ throw new HimanError(errorCodes.INVALID_INPUT, `Source branch not found: ${branch}`);
284
+ }
285
+ async hasGitRef(git, ref) {
286
+ try {
287
+ await git.raw(["rev-parse", "--verify", ref]);
288
+ return true;
289
+ }
290
+ catch {
291
+ return false;
292
+ }
293
+ }
294
+ async readDirectorySnapshot(targetDir) {
295
+ const files = await this.listFiles(targetDir);
296
+ const snapshot = new Map();
297
+ for (const file of files) {
298
+ const relative = path.relative(targetDir, file).split(path.sep).join("/");
299
+ snapshot.set(relative, await fs.readFile(file, "utf8"));
300
+ }
301
+ return snapshot;
302
+ }
303
+ async listFiles(targetDir) {
304
+ const entries = await fs.readdir(targetDir, { withFileTypes: true });
305
+ const files = [];
306
+ for (const entry of entries) {
307
+ const fullPath = path.join(targetDir, entry.name);
308
+ if (entry.isDirectory()) {
309
+ files.push(...(await this.listFiles(fullPath)));
310
+ }
311
+ else if (entry.isFile()) {
312
+ files.push(fullPath);
313
+ }
314
+ }
315
+ return files.sort((a, b) => a.localeCompare(b));
316
+ }
317
+ snapshotsEqual(a, b) {
318
+ if (a.size !== b.size)
319
+ return false;
320
+ for (const [file, content] of a) {
321
+ if (b.get(file) !== content)
322
+ return false;
323
+ }
324
+ return true;
325
+ }
326
+ getResourcePath(resource) {
327
+ return `${resource.type}s/${resource.name}`;
328
+ }
83
329
  async pushCurrentBranch(git, branch) {
84
330
  const currentBranch = (await git.raw(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
85
331
  const targetBranch = branch ?? currentBranch;
@@ -0,0 +1,42 @@
1
+ import { createHash } from "node:crypto";
2
+ const TOKENIZER = "approx-char-v1";
3
+ const TOKEN_ESTIMATOR = "ceil(chars/4)";
4
+ export function buildResourceAnalysisMetadata(input) {
5
+ const measuredAt = input.measuredAt ?? new Date();
6
+ const packageFiles = input.packageFiles?.length
7
+ ? input.packageFiles
8
+ : [{ path: input.entry, content: input.entryContent }];
9
+ return {
10
+ content: {
11
+ tokenizer: TOKENIZER,
12
+ tokenEstimator: TOKEN_ESTIMATOR,
13
+ entryTokens: estimateTokens(input.entryContent),
14
+ packageTokens: estimateTokens(packageFiles.map((file) => file.content).join("\n")),
15
+ contentHash: hashPackageFiles(packageFiles),
16
+ measuredAt: measuredAt.toISOString(),
17
+ measuredBy: input.measuredBy,
18
+ },
19
+ dependencies: {
20
+ skills: [],
21
+ scripts: [],
22
+ mcpTools: [],
23
+ },
24
+ generation: {
25
+ generatedBy: input.generatedBy,
26
+ generatedAt: measuredAt.toISOString(),
27
+ },
28
+ };
29
+ }
30
+ function estimateTokens(content) {
31
+ return Math.ceil(content.length / 4);
32
+ }
33
+ function hashPackageFiles(files) {
34
+ const hash = createHash("sha256");
35
+ for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
36
+ hash.update(file.path);
37
+ hash.update("\0");
38
+ hash.update(file.content);
39
+ hash.update("\0");
40
+ }
41
+ return `sha256:${hash.digest("hex")}`;
42
+ }
@@ -1,5 +1,6 @@
1
1
  import { RepoManager } from "../git/repo-manager.js";
2
2
  import { ResourceScanner } from "../resource/resource-scanner.js";
3
+ import { buildResourceAnalysisMetadata } from "../resource/resource-analysis.js";
3
4
  import semver from "semver";
4
5
  import { HimanError, errorCodes } from "../../utils/errors.js";
5
6
  import { promises as fs } from "node:fs";
@@ -85,19 +86,13 @@ export class GitSourceAdapter {
85
86
  if (resourceExists && !options.force) {
86
87
  throw new HimanError(errorCodes.RESOURCE_EXISTS, `Resource already exists: ${type}/${name}`);
87
88
  }
89
+ const entryContent = this.getDefaultContent(type, name);
88
90
  const files = [path.join(resourceDir, "himan.yaml"), path.join(resourceDir, entry)];
89
91
  if (!options.dryRun) {
90
92
  await fs.rm(resourceDir, { recursive: true, force: true });
91
93
  await fs.mkdir(resourceDir, { recursive: true });
92
- await fs.writeFile(path.join(resourceDir, "himan.yaml"), YAML.stringify({
93
- name,
94
- type,
95
- version: "0.1.0",
96
- entry,
97
- description: options.description ?? `${type} resource ${name}`,
98
- agents,
99
- }), "utf8");
100
- await fs.writeFile(path.join(resourceDir, entry), this.getDefaultContent(type, name), "utf8");
94
+ await fs.writeFile(path.join(resourceDir, "himan.yaml"), YAML.stringify(this.buildCreateResourceMetadata(type, name, entry, entryContent, options.description ?? `${type} resource ${name}`, agents)), "utf8");
95
+ await fs.writeFile(path.join(resourceDir, entry), entryContent, "utf8");
101
96
  await this.maintainSourceDocs(repoDir, {
102
97
  section: resourceExists ? "Changed" : "Added",
103
98
  line: resourceExists
@@ -113,6 +108,73 @@ export class GitSourceAdapter {
113
108
  dryRun: Boolean(options.dryRun),
114
109
  };
115
110
  }
111
+ async rename(type, oldName, newName, options = {}) {
112
+ const repoDir = this.getRepoDir();
113
+ const typeDir = this.getTypeDir(type);
114
+ const previousResourceDir = path.join(repoDir, typeDir, oldName);
115
+ const resourceDir = path.join(repoDir, typeDir, newName);
116
+ if (!(await this.exists(previousResourceDir))) {
117
+ throw new HimanError(errorCodes.RESOURCE_NOT_FOUND, `Resource not found: ${type}/${oldName}`);
118
+ }
119
+ await this.ensureRenameTargetAvailable(repoDir, type, newName, resourceDir);
120
+ const history = await this.history(type, oldName);
121
+ const latestVersion = history[0]?.version;
122
+ const tag = latestVersion ? `${type}/${newName}@${latestVersion}` : undefined;
123
+ if (options.dryRun) {
124
+ return {
125
+ type,
126
+ oldName,
127
+ newName,
128
+ previousResourceDir,
129
+ resourceDir,
130
+ latestVersion,
131
+ tag,
132
+ committed: false,
133
+ dryRun: true,
134
+ };
135
+ }
136
+ await fs.mkdir(path.dirname(resourceDir), { recursive: true });
137
+ await fs.rename(previousResourceDir, resourceDir);
138
+ await this.updateRenamedResourceMetadata(resourceDir, type, oldName, newName);
139
+ const versionOverrides = latestVersion
140
+ ? new Map([[this.getResourceVersionOverrideKey(type, newName), latestVersion]])
141
+ : new Map();
142
+ const docsPaths = await this.maintainSourceDocs(repoDir, {
143
+ section: "Changed",
144
+ line: `- Renamed \`${type}/${oldName}\` to \`${type}/${newName}\`.`,
145
+ }, versionOverrides);
146
+ const changedPaths = [
147
+ path.relative(repoDir, previousResourceDir),
148
+ path.relative(repoDir, resourceDir),
149
+ ...docsPaths.map((docPath) => path.relative(repoDir, docPath)),
150
+ ];
151
+ if (tag) {
152
+ await this.repoManager.commitTagAndPush(repoDir, `rename ${type}/${oldName} to ${type}/${newName}`, tag, undefined, changedPaths);
153
+ return {
154
+ type,
155
+ oldName,
156
+ newName,
157
+ previousResourceDir,
158
+ resourceDir,
159
+ latestVersion,
160
+ tag,
161
+ committed: true,
162
+ dryRun: false,
163
+ };
164
+ }
165
+ const committed = await this.repoManager.commitAndPush(repoDir, `rename ${type}/${oldName} to ${type}/${newName}`, undefined, changedPaths);
166
+ return {
167
+ type,
168
+ oldName,
169
+ newName,
170
+ previousResourceDir,
171
+ resourceDir,
172
+ latestVersion,
173
+ tag,
174
+ committed,
175
+ dryRun: false,
176
+ };
177
+ }
116
178
  async initDocs(options = {}) {
117
179
  const repoDir = this.getRepoDir();
118
180
  const files = [
@@ -147,6 +209,13 @@ export class GitSourceAdapter {
147
209
  committed,
148
210
  };
149
211
  }
212
+ async cloneTo(targetRepo, options = {}) {
213
+ return this.repoManager.cloneManagedSourceRefs(this.getRepoDir(), targetRepo, options);
214
+ }
215
+ async syncLatestTo(targetRepo, options = {}) {
216
+ const resources = await this.collectLatestVersionedResources();
217
+ return this.repoManager.syncLatestSourceSnapshot(this.getRepoDir(), targetRepo, resources, options);
218
+ }
150
219
  getRepoDir() {
151
220
  if (!this.sourceConfig?.repoDir) {
152
221
  throw new HimanError(errorCodes.CONFIG_NOT_FOUND, "Git source is not initialized.");
@@ -171,6 +240,65 @@ export class GitSourceAdapter {
171
240
  return false;
172
241
  }
173
242
  }
243
+ async ensureRenameTargetAvailable(repoDir, type, newName, resourceDir) {
244
+ if (await this.exists(resourceDir)) {
245
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, `Resource already exists: ${type}/${newName}`);
246
+ }
247
+ const [resources, history] = await Promise.all([
248
+ this.scanner.scanByType(repoDir, type),
249
+ this.history(type, newName),
250
+ ]);
251
+ if (resources.some((resource) => resource.name === newName) || history.length > 0) {
252
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, `Resource already exists: ${type}/${newName}`);
253
+ }
254
+ }
255
+ async updateRenamedResourceMetadata(resourceDir, type, oldName, newName) {
256
+ const yamlPath = path.join(resourceDir, "himan.yaml");
257
+ if (await this.exists(yamlPath)) {
258
+ const raw = await fs.readFile(yamlPath, "utf8");
259
+ let parsed;
260
+ try {
261
+ parsed = YAML.parse(raw);
262
+ }
263
+ catch (error) {
264
+ throw this.invalidResourceMetadata(type, oldName, "himan.yaml is not valid YAML.", { yamlPath, reason: error instanceof Error ? error.message : String(error) });
265
+ }
266
+ if (!this.isRecord(parsed)) {
267
+ throw this.invalidResourceMetadata(type, oldName, "himan.yaml must be an object.", { yamlPath });
268
+ }
269
+ await fs.writeFile(yamlPath, YAML.stringify({
270
+ ...parsed,
271
+ name: newName,
272
+ }), "utf8");
273
+ return;
274
+ }
275
+ if (type !== "skill")
276
+ return;
277
+ await this.updateSkillFrontMatterName(path.join(resourceDir, this.getDefaultEntry(type)), oldName, newName);
278
+ }
279
+ async updateSkillFrontMatterName(skillPath, oldName, newName) {
280
+ if (!(await this.exists(skillPath)))
281
+ return;
282
+ const raw = await fs.readFile(skillPath, "utf8");
283
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
284
+ if (!match)
285
+ return;
286
+ let parsed;
287
+ try {
288
+ parsed = YAML.parse(match[1]);
289
+ }
290
+ catch {
291
+ return;
292
+ }
293
+ if (!this.isRecord(parsed) || parsed.name !== oldName)
294
+ return;
295
+ const frontMatter = YAML.stringify({
296
+ ...parsed,
297
+ name: newName,
298
+ }).trimEnd();
299
+ const updated = `---\n${frontMatter}\n---\n${raw.slice(match[0].length)}`;
300
+ await fs.writeFile(skillPath, updated, "utf8");
301
+ }
174
302
  async validatePublishResource(type, name, resourceDir) {
175
303
  const yamlPath = path.join(resourceDir, "himan.yaml");
176
304
  if (!(await this.exists(yamlPath))) {
@@ -406,6 +534,25 @@ export class GitSourceAdapter {
406
534
  }
407
535
  return `# ${name}\n\nDescribe skill workflow here.\n`;
408
536
  }
537
+ buildCreateResourceMetadata(type, name, entry, entryContent, description, agents) {
538
+ const metadata = {
539
+ name,
540
+ type,
541
+ version: "0.1.0",
542
+ entry,
543
+ description,
544
+ agents,
545
+ };
546
+ if (type === "skill") {
547
+ metadata.analysis = buildResourceAnalysisMetadata({
548
+ entry,
549
+ entryContent,
550
+ measuredBy: "himan",
551
+ generatedBy: "himan",
552
+ });
553
+ }
554
+ return metadata;
555
+ }
409
556
  async buildReadmeContent(repoDir, versionOverrides = new Map()) {
410
557
  const resourceLines = await this.buildResourceIndex(repoDir, versionOverrides);
411
558
  const repo = this.sourceConfig?.repo ?? "<git_url>";
@@ -666,6 +813,38 @@ export class GitSourceAdapter {
666
813
  return undefined;
667
814
  }
668
815
  }
816
+ async collectLatestVersionedResources() {
817
+ const repoDir = this.getRepoDir();
818
+ const resources = [];
819
+ for (const type of RESOURCE_TYPES) {
820
+ const scanned = await this.scanner.scanByType(repoDir, type);
821
+ for (const resource of scanned) {
822
+ const history = await this.history(type, resource.name);
823
+ const latest = history[0];
824
+ const metadataVersion = await this.readResourceVersion(repoDir, type, resource.name);
825
+ const version = latest?.version ?? metadataVersion;
826
+ if (!version || !semver.valid(version)) {
827
+ throw new HimanError(errorCodes.VERSION_NOT_FOUND, `Latest version not found for ${type}/${resource.name}.`);
828
+ }
829
+ resources.push({
830
+ type,
831
+ name: resource.name,
832
+ version,
833
+ tag: `${type}/${resource.name}@${version}`,
834
+ sourceRef: latest ? latest.raw : undefined,
835
+ sourcePath: latest
836
+ ? undefined
837
+ : path.join(repoDir, this.getTypeDir(type), resource.name),
838
+ });
839
+ }
840
+ }
841
+ return resources.sort((a, b) => {
842
+ const typeOrder = RESOURCE_TYPES.indexOf(a.type) - RESOURCE_TYPES.indexOf(b.type);
843
+ if (typeOrder !== 0)
844
+ return typeOrder;
845
+ return a.name.localeCompare(b.name);
846
+ });
847
+ }
669
848
  async getResourceRef(repoDir, type, name, versionOverrides = new Map()) {
670
849
  const version = versionOverrides.get(this.getResourceVersionOverrideKey(type, name)) ??
671
850
  (await this.readLatestTaggedResourceVersion(repoDir, type, name)) ??
@@ -18,6 +18,9 @@ export class RegistrySourceAdapter {
18
18
  async create(_type, _name, _options) {
19
19
  throw new HimanError(errorCodes.NOT_IMPLEMENTED, "Registry source is reserved for phase 2.");
20
20
  }
21
+ async rename(_type, _oldName, _newName, _options) {
22
+ throw new HimanError(errorCodes.NOT_IMPLEMENTED, "Registry source is reserved for phase 2.");
23
+ }
21
24
  async initDocs(_options) {
22
25
  throw new HimanError(errorCodes.NOT_IMPLEMENTED, "Registry source is reserved for phase 2.");
23
26
  }
@@ -60,10 +60,11 @@ function appendCommandGroupsHelp(program) {
60
60
  program.addHelpText("after", `
61
61
  Command groups:
62
62
  source Data source management (git now, registry reserved)
63
- init, source init, source add, source use, source list, source init-docs
63
+ init, source init, source add, source use, source list,
64
+ source init-docs, source clone, source sync
64
65
  resource Source resource discovery and metadata
65
- list, list --installed, history, create,
66
- resource list, resource history, resource create
66
+ list, list --installed, history, create, rename (not recommended yet),
67
+ resource list, resource history, resource create, resource rename
67
68
  project Resource usage lifecycle in current project or user-level agent dirs
68
69
  list, install, dev, uninstall, publish,
69
70
  project list, project install, project dev, project uninstall, project publish
@@ -91,6 +91,29 @@ export function registerResourceCommands(command, services) {
91
91
  process.stdout.write(`Created ${result.type}/${result.name} at ${result.resourceDir}${result.dryRun ? " (dry-run)" : ""}\n`);
92
92
  });
93
93
  });
94
+ command
95
+ .command("rename")
96
+ .argument("<type>", "resource type")
97
+ .argument("<old-name>", "current resource name")
98
+ .argument("<new-name>", "new resource name")
99
+ .option("--dry-run", "show rename result without writing")
100
+ .option("--no-project", "do not migrate current project install targets or lock")
101
+ .option("--json", "output json format")
102
+ .description("Rename resource in current default source (not recommended yet)")
103
+ .action(async (type, oldName, newName, options) => {
104
+ await runAction(async () => {
105
+ const resourceType = ensureResourceType(type);
106
+ const result = await services.rename(resourceType, oldName, newName, process.cwd(), {
107
+ dryRun: options.dryRun,
108
+ migrateProject: options.project,
109
+ });
110
+ if (options.json) {
111
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
112
+ return;
113
+ }
114
+ process.stdout.write(`Renamed ${result.type}/${result.oldName} to ${result.type}/${result.newName}${result.dryRun ? " (dry-run)" : ""}\n`);
115
+ });
116
+ });
94
117
  }
95
118
  async function writeInstalledList(services, type, agents, json) {
96
119
  if (!type) {
@@ -12,7 +12,7 @@ export function createBaseProgram(name, description) {
12
12
  // Parse/usage errors are unified by writeCliError().
13
13
  },
14
14
  });
15
- program.name(name).description(description).version(PACKAGE_VERSION);
15
+ program.name(name).description(description).version(PACKAGE_VERSION, "-v, --version");
16
16
  return program;
17
17
  }
18
18
  export async function runAction(action) {
@@ -35,6 +35,80 @@ export function registerSourceCommands(command, services, options) {
35
35
  process.stdout.write(`Using source: ${result.name}\n`);
36
36
  });
37
37
  });
38
+ command
39
+ .command("clone")
40
+ .argument("<from>", "source name or git repository URL")
41
+ .argument("<to>", "target source name or git repository URL")
42
+ .option("--branch <branch>", "source branch to clone")
43
+ .option("--target-branch <branch>", "target branch name")
44
+ .option("--add-source <name>", "add the target git repo as a named source after clone")
45
+ .option("--use", "switch default source to the target source after clone")
46
+ .option("--dry-run", "show refs without pushing")
47
+ .option("--json", "output json format")
48
+ .description("Clone a git source into an empty target git repository")
49
+ .action(async (from, to, options) => {
50
+ await runAction(async () => {
51
+ const result = await services.cloneSource(from, to, {
52
+ branch: options.branch,
53
+ targetBranch: options.targetBranch,
54
+ addSource: options.addSource,
55
+ use: options.use,
56
+ dryRun: options.dryRun,
57
+ });
58
+ if (options.json) {
59
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
60
+ return;
61
+ }
62
+ process.stdout.write(`Source clone ${result.dryRun ? "dry-run" : "completed"}: ${result.source.name ?? result.source.repo} -> ${result.target.name ?? result.target.repo}\n`);
63
+ process.stdout.write(`- branch ${result.branch} -> ${result.targetBranch}\n`);
64
+ process.stdout.write(`- resource tags: ${result.tags.length}\n`);
65
+ if (result.addedSource) {
66
+ process.stdout.write(`- added source: ${result.addedSource}\n`);
67
+ }
68
+ if (result.usedSource) {
69
+ process.stdout.write(`- using source: ${result.usedSource}\n`);
70
+ }
71
+ });
72
+ });
73
+ command
74
+ .command("sync")
75
+ .argument("<from>", "source name or git repository URL")
76
+ .argument("<to>", "target source name or git repository URL")
77
+ .option("--target-branch <branch>", "target branch name", "main")
78
+ .option("--add-source <name>", "add the target git repo as a named source after sync")
79
+ .option("--use", "switch default source to the target source after sync")
80
+ .option("--dry-run", "show resources without pushing")
81
+ .option("--json", "output json format")
82
+ .description("Sync latest source resource snapshots into a target git repository")
83
+ .action(async (from, to, options) => {
84
+ await runAction(async () => {
85
+ const result = await services.syncSource(from, to, {
86
+ targetBranch: options.targetBranch,
87
+ addSource: options.addSource,
88
+ use: options.use,
89
+ dryRun: options.dryRun,
90
+ });
91
+ if (options.json) {
92
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
93
+ return;
94
+ }
95
+ const created = result.resources.filter((resource) => resource.action === "created").length;
96
+ const skipped = result.resources.length - created;
97
+ process.stdout.write(`Source sync ${result.dryRun ? "dry-run" : "completed"}: ${result.source.name ?? result.source.repo} -> ${result.target.name ?? result.target.repo}\n`);
98
+ process.stdout.write(`- target branch: ${result.targetBranch}\n`);
99
+ process.stdout.write(`- resources: ${result.resources.length}\n`);
100
+ process.stdout.write(`- tags created: ${created}\n`);
101
+ if (skipped > 0) {
102
+ process.stdout.write(`- tags skipped: ${skipped}\n`);
103
+ }
104
+ if (result.addedSource) {
105
+ process.stdout.write(`- added source: ${result.addedSource}\n`);
106
+ }
107
+ if (result.usedSource) {
108
+ process.stdout.write(`- using source: ${result.usedSource}\n`);
109
+ }
110
+ });
111
+ });
38
112
  command
39
113
  .command("list")
40
114
  .option("--json", "output json format")
@@ -0,0 +1 @@
1
+ export {};
@@ -43,9 +43,7 @@ export class ServiceFactory {
43
43
  };
44
44
  }
45
45
  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
- }
46
+ this.validateSourceName(name);
49
47
  await this.stateStore.ensureBaseDirs();
50
48
  const sourceConfig = this.buildSourceConfig(type, repo);
51
49
  const source = this.createSource(type);
@@ -105,6 +103,47 @@ export class ServiceFactory {
105
103
  const source = await this.loadSourceFromConfig();
106
104
  return source.initDocs(options);
107
105
  }
106
+ async cloneSource(from, to, options = {}) {
107
+ await this.stateStore.ensureBaseDirs();
108
+ this.validateSourceTransferOptions(to, options);
109
+ const sourceEndpoint = await this.resolveGitSourceEndpoint(from);
110
+ const targetEndpoint = await this.resolveGitSourceEndpoint(to);
111
+ this.validateSourceTransferUseTarget(targetEndpoint, options);
112
+ this.ensureDifferentGitSources(sourceEndpoint, targetEndpoint);
113
+ const source = await this.loadGitSourceFromEndpoint(sourceEndpoint);
114
+ const result = await source.cloneTo(targetEndpoint.repo, {
115
+ branch: options.branch,
116
+ targetBranch: options.targetBranch,
117
+ dryRun: options.dryRun,
118
+ });
119
+ const configUpdates = await this.applySourceTransferConfigUpdates(targetEndpoint, options);
120
+ return {
121
+ source: sourceEndpoint,
122
+ target: targetEndpoint,
123
+ ...result,
124
+ ...configUpdates,
125
+ };
126
+ }
127
+ async syncSource(from, to, options = {}) {
128
+ await this.stateStore.ensureBaseDirs();
129
+ this.validateSourceTransferOptions(to, options);
130
+ const sourceEndpoint = await this.resolveGitSourceEndpoint(from);
131
+ const targetEndpoint = await this.resolveGitSourceEndpoint(to);
132
+ this.validateSourceTransferUseTarget(targetEndpoint, options);
133
+ this.ensureDifferentGitSources(sourceEndpoint, targetEndpoint);
134
+ const source = await this.loadGitSourceFromEndpoint(sourceEndpoint);
135
+ const result = await source.syncLatestTo(targetEndpoint.repo, {
136
+ targetBranch: options.targetBranch,
137
+ dryRun: options.dryRun,
138
+ });
139
+ const configUpdates = await this.applySourceTransferConfigUpdates(targetEndpoint, options);
140
+ return {
141
+ source: sourceEndpoint,
142
+ target: targetEndpoint,
143
+ ...result,
144
+ ...configUpdates,
145
+ };
146
+ }
108
147
  async setAgents(agents, scope, projectDir) {
109
148
  const normalized = normalizeAgents(agents);
110
149
  if (scope === "project") {
@@ -183,11 +222,11 @@ export class ServiceFactory {
183
222
  const source = await this.loadSourceFromConfig();
184
223
  return source.history(type, name);
185
224
  }
186
- async install(type, name, version, projectDir, agents, mode = "link") {
225
+ async install(type, name, version, projectDir, agents, mode = "copy") {
187
226
  const { source, sourceInfo } = await this.loadSourceWithInfoFromConfig();
188
227
  return this.installWithSource(source, sourceInfo, type, name, version, projectDir, agents, mode);
189
228
  }
190
- async installGlobal(type, name, version, projectDir, agents, mode = "link") {
229
+ async installGlobal(type, name, version, projectDir, agents, mode = "copy") {
191
230
  const source = await this.loadSourceFromConfig();
192
231
  return this.installWithSource(source, undefined, type, name, version, projectDir, agents, mode, "global");
193
232
  }
@@ -274,6 +313,31 @@ export class ServiceFactory {
274
313
  dryRun: options.dryRun,
275
314
  });
276
315
  }
316
+ async rename(type, oldName, newName, projectDir, options = {}) {
317
+ this.validateRenameInput(type, oldName, newName);
318
+ const source = await this.loadSourceFromConfig();
319
+ const shouldMigrateProject = options.migrateProject !== false && !options.dryRun;
320
+ const locked = shouldMigrateProject
321
+ ? await this.getLockedResource(projectDir, type, oldName)
322
+ : undefined;
323
+ const installInfo = shouldMigrateProject
324
+ ? await this.tryResolveInstalledResource(projectDir, type, oldName)
325
+ : undefined;
326
+ const hasDevPath = shouldMigrateProject &&
327
+ (await this.exists(this.getProjectDevPath(projectDir, type, oldName)));
328
+ if (shouldMigrateProject && (locked || installInfo || hasDevPath)) {
329
+ await this.ensureRenamedProjectResourceAvailable(projectDir, type, oldName, newName, locked, installInfo);
330
+ }
331
+ const result = await source.rename(type, oldName, newName, {
332
+ dryRun: options.dryRun,
333
+ });
334
+ const projectMigrated = shouldMigrateProject &&
335
+ (await this.migrateRenamedProjectResource(source, type, oldName, newName, projectDir, result, locked, installInfo));
336
+ return {
337
+ ...result,
338
+ projectMigrated,
339
+ };
340
+ }
277
341
  async installFromLock(projectDir, agents, mode) {
278
342
  const { lock, state } = await this.lockStore.loadWithState(projectDir);
279
343
  if (state === "missing") {
@@ -331,6 +395,76 @@ export class ServiceFactory {
331
395
  async loadSourceFromConfig() {
332
396
  return (await this.loadSourceWithInfoFromConfig()).source;
333
397
  }
398
+ async resolveGitSourceEndpoint(ref) {
399
+ const config = await this.stateStore.loadConfig();
400
+ const configured = config?.sources?.items[ref];
401
+ if (configured) {
402
+ if (configured.type !== "git" || !configured.repo) {
403
+ throw new HimanError(errorCodes.INVALID_INPUT, `Source is not a git source: ${ref}`);
404
+ }
405
+ return {
406
+ name: ref,
407
+ repo: configured.repo,
408
+ repoId: configured.repoId ?? toRepoId(configured.repo),
409
+ };
410
+ }
411
+ if (!ref.trim()) {
412
+ throw new HimanError(errorCodes.INVALID_INPUT, "Git repo is required.");
413
+ }
414
+ return {
415
+ repo: ref,
416
+ repoId: toRepoId(ref),
417
+ };
418
+ }
419
+ async loadGitSourceFromEndpoint(endpoint) {
420
+ const sourceConfig = this.buildSourceConfig("git", endpoint.repo, endpoint.repoId);
421
+ const source = new GitSourceAdapter();
422
+ await source.init(sourceConfig);
423
+ return source;
424
+ }
425
+ validateSourceTransferOptions(targetRef, options) {
426
+ if (options.addSource) {
427
+ this.validateSourceName(options.addSource);
428
+ }
429
+ if (!options.use || options.addSource) {
430
+ return;
431
+ }
432
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(targetRef)) {
433
+ throw new HimanError(errorCodes.INVALID_INPUT, "`--use` requires the target to be a configured source name or `--add-source <name>`.");
434
+ }
435
+ }
436
+ validateSourceTransferUseTarget(target, options) {
437
+ if (options.use && !options.addSource && !target.name) {
438
+ throw new HimanError(errorCodes.INVALID_INPUT, "`--use` requires the target to be a configured source name or `--add-source <name>`.");
439
+ }
440
+ }
441
+ ensureDifferentGitSources(source, target) {
442
+ if (source.repo === target.repo) {
443
+ throw new HimanError(errorCodes.INVALID_INPUT, "Source and target repositories must be different.");
444
+ }
445
+ }
446
+ async applySourceTransferConfigUpdates(target, options) {
447
+ if (options.dryRun)
448
+ return {};
449
+ let addedSource;
450
+ if (options.addSource) {
451
+ await this.addSource(options.addSource, "git", target.repo);
452
+ addedSource = options.addSource;
453
+ }
454
+ const sourceToUse = options.use ? options.addSource ?? target.name : undefined;
455
+ if (sourceToUse) {
456
+ await this.useSource(sourceToUse);
457
+ }
458
+ return {
459
+ addedSource,
460
+ usedSource: sourceToUse,
461
+ };
462
+ }
463
+ validateSourceName(name) {
464
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
465
+ throw new HimanError(errorCodes.INVALID_INPUT, `Invalid source name: ${name}`);
466
+ }
467
+ }
334
468
  async loadSourceWithInfoFromConfig() {
335
469
  const { name, source: stateSource } = await this.getCurrentSourceState();
336
470
  const sourceInfo = this.toLockSourceInfo(stateSource, name);
@@ -391,6 +525,75 @@ export class ServiceFactory {
391
525
  return undefined;
392
526
  return lock.resources.find((item) => item.type === type && item.name === name);
393
527
  }
528
+ async ensureRenamedProjectResourceAvailable(projectDir, type, oldName, newName, locked, installInfo) {
529
+ const lockedNewName = await this.getLockedResource(projectDir, type, newName);
530
+ if (lockedNewName) {
531
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, `Installed resource already exists: ${type}/${newName}`);
532
+ }
533
+ const newDevPath = this.getProjectDevPath(projectDir, type, newName);
534
+ if (await this.exists(newDevPath)) {
535
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, `Development resource already exists: ${type}/${newName}`);
536
+ }
537
+ const agents = locked?.agents?.length
538
+ ? normalizeAgents(locked.agents)
539
+ : installInfo?.agents;
540
+ if (!agents?.length)
541
+ return;
542
+ for (const linkPath of getProjectResourcePaths(projectDir, type, newName, agents)) {
543
+ if (await this.exists(linkPath)) {
544
+ throw new HimanError(errorCodes.RESOURCE_EXISTS, `Installed resource already exists: ${type}/${newName}`);
545
+ }
546
+ }
547
+ }
548
+ async migrateRenamedProjectResource(source, type, oldName, newName, projectDir, result, locked, installInfo) {
549
+ const oldDevPath = this.getProjectDevPath(projectDir, type, oldName);
550
+ const newDevPath = this.getProjectDevPath(projectDir, type, newName);
551
+ const hasDevPath = await this.exists(oldDevPath);
552
+ if (!locked && !installInfo && !hasDevPath) {
553
+ return false;
554
+ }
555
+ let sourcePath;
556
+ if (hasDevPath) {
557
+ await fs.mkdir(path.dirname(newDevPath), { recursive: true });
558
+ await fs.rename(oldDevPath, newDevPath);
559
+ await this.updateRenamedResourceMetadata(newDevPath, type, oldName, newName);
560
+ sourcePath = newDevPath;
561
+ }
562
+ else if (result.latestVersion) {
563
+ const storePath = this.getStorePath(type, newName, result.latestVersion);
564
+ if (!(await this.exists(storePath))) {
565
+ await source.pull(type, newName, result.latestVersion, storePath);
566
+ }
567
+ sourcePath = storePath;
568
+ }
569
+ else if (installInfo) {
570
+ sourcePath = installInfo.installedPath;
571
+ }
572
+ else {
573
+ sourcePath = result.resourceDir;
574
+ }
575
+ const agents = locked?.agents?.length
576
+ ? normalizeAgents(locked.agents)
577
+ : installInfo?.agents ?? normalizeAgents();
578
+ const mode = installInfo?.mode ?? this.resolveInstallMode(locked?.mode);
579
+ const oldLinkPaths = installInfo?.linkPaths ?? getProjectResourcePaths(projectDir, type, oldName, agents);
580
+ const newLinkPaths = getProjectResourcePaths(projectDir, type, newName, agents);
581
+ for (const linkPath of newLinkPaths) {
582
+ await this.materializeResource(sourcePath, linkPath, mode);
583
+ }
584
+ for (const linkPath of oldLinkPaths) {
585
+ await fs.rm(linkPath, { recursive: true, force: true });
586
+ }
587
+ if (locked) {
588
+ await this.lockStore.renameResource(projectDir, {
589
+ type,
590
+ oldName,
591
+ newName,
592
+ version: result.latestVersion ?? locked.version,
593
+ });
594
+ }
595
+ return true;
596
+ }
394
597
  buildSourceConfig(type, repo, repoId) {
395
598
  if (type === "registry") {
396
599
  return { type };
@@ -431,7 +634,7 @@ export class ServiceFactory {
431
634
  await fs.symlink(sourcePath, targetPath, "dir");
432
635
  }
433
636
  resolveInstallMode(mode) {
434
- return mode === "copy" ? "copy" : "link";
637
+ return mode === "link" ? "link" : "copy";
435
638
  }
436
639
  async resolveInstalledResource(projectDir, type, name) {
437
640
  const locked = await this.getLockedResource(projectDir, type, name);
@@ -546,6 +749,48 @@ export class ServiceFactory {
546
749
  this.readStringArrayMetadata(metadata, "targets"),
547
750
  };
548
751
  }
752
+ async updateRenamedResourceMetadata(resourceDir, type, oldName, newName) {
753
+ const yamlPath = path.join(resourceDir, "himan.yaml");
754
+ if (await this.exists(yamlPath)) {
755
+ const raw = await fs.readFile(yamlPath, "utf8");
756
+ const parsed = YAML.parse(raw);
757
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
758
+ return;
759
+ }
760
+ await fs.writeFile(yamlPath, YAML.stringify({
761
+ ...parsed,
762
+ name: newName,
763
+ }), "utf8");
764
+ return;
765
+ }
766
+ if (type !== "skill")
767
+ return;
768
+ const entryPath = path.join(resourceDir, this.getDefaultEntry(type));
769
+ if (!(await this.exists(entryPath)))
770
+ return;
771
+ const raw = await fs.readFile(entryPath, "utf8");
772
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
773
+ if (!match)
774
+ return;
775
+ let parsed;
776
+ try {
777
+ parsed = YAML.parse(match[1]);
778
+ }
779
+ catch {
780
+ return;
781
+ }
782
+ if (typeof parsed !== "object" ||
783
+ parsed === null ||
784
+ Array.isArray(parsed) ||
785
+ parsed.name !== oldName) {
786
+ return;
787
+ }
788
+ const frontMatter = YAML.stringify({
789
+ ...parsed,
790
+ name: newName,
791
+ }).trimEnd();
792
+ await fs.writeFile(entryPath, `---\n${frontMatter}\n---\n${raw.slice(match[0].length)}`, "utf8");
793
+ }
549
794
  async readFrontMatter(filePath) {
550
795
  const raw = await fs.readFile(filePath, "utf8");
551
796
  const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw.trimStart());
@@ -623,4 +868,15 @@ export class ServiceFactory {
623
868
  throw new HimanError(errorCodes.TEMPLATE_NOT_FOUND, `Template not found: ${options.template}`);
624
869
  }
625
870
  }
871
+ validateRenameInput(type, oldName, newName) {
872
+ if (!["rule", "command", "skill"].includes(type)) {
873
+ throw new HimanError(errorCodes.UNSUPPORTED_RESOURCE_TYPE, `Unsupported resource type for rename: ${type}`);
874
+ }
875
+ if (oldName === newName) {
876
+ throw new HimanError(errorCodes.INVALID_INPUT, "Old and new resource names must be different.");
877
+ }
878
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(newName)) {
879
+ throw new HimanError(errorCodes.INVALID_RESOURCE_NAME, `Invalid resource name: ${newName}. Use kebab-case only.`);
880
+ }
881
+ }
626
882
  }
@@ -73,4 +73,23 @@ export class ProjectLockStore {
73
73
  lock.updatedAt = new Date().toISOString();
74
74
  await fs.writeFile(this.getLockPath(projectDir), JSON.stringify(lock, null, 2), "utf8");
75
75
  }
76
+ async renameResource(projectDir, resource) {
77
+ const lock = await this.load(projectDir);
78
+ if (!lock)
79
+ return;
80
+ const found = lock.resources.find((item) => item.type === resource.type && item.name === resource.oldName);
81
+ if (!found)
82
+ return;
83
+ const now = new Date().toISOString();
84
+ found.name = resource.newName;
85
+ found.version = resource.version ?? found.version;
86
+ found.updatedAt = now;
87
+ lock.updatedAt = now;
88
+ lock.resources.sort((a, b) => {
89
+ if (a.type !== b.type)
90
+ return a.type.localeCompare(b.type);
91
+ return a.name.localeCompare(b.name);
92
+ });
93
+ await fs.writeFile(this.getLockPath(projectDir), JSON.stringify(lock, null, 2), "utf8");
94
+ }
76
95
  }
@@ -13,7 +13,7 @@
13
13
  ```bash
14
14
  pnpm install
15
15
  pnpm run build
16
- pnpm run dev -- --help
16
+ pnpm cli --help
17
17
  node dist/bin/himan.js --help
18
18
  ```
19
19
 
@@ -28,6 +28,7 @@ pnpm test
28
28
  | 命令 | 作用 |
29
29
  |------|------|
30
30
  | `pnpm run clean` | 删除 `dist/` |
31
+ | `pnpm cli <subcommand>` | 从源码运行主 CLI |
31
32
  | `pnpm run build` | 清理并编译 TypeScript 到 `dist/` |
32
33
  | `pnpm run typecheck` | 运行 TypeScript 类型检查,不输出文件 |
33
34
  | `pnpm test` | 运行 Vitest 一次 |
@@ -43,7 +43,7 @@
43
43
  - 也支持 `himan install`(无参数)按 `himan.lock` 批量复现安装。
44
44
  - 未指定版本则安装该资源最新 tag 对应版本。
45
45
  - 若本地 store 中已有该版本缓存,则复用、不重新从 Git 导出;否则导出到 store。
46
- - 在项目下按安装模式创建目标(默认 `--mode link` 软链;`--mode copy` 复制)。
46
+ - 在项目下按安装模式创建目标(默认 `--mode copy` 复制;`--mode link` 软链)。
47
47
  - 目标路径由 agent 和资源类型共同决定:
48
48
  - `cursor` -> `.cursor/{rules|commands|skills}/<name>`
49
49
  - `claude-code` -> `.claude/{rules|commands|skills}/<name>`
@@ -91,7 +91,7 @@
91
91
  - **领域**:资源类型、版本、路径约定
92
92
  - **适配**:Git 实现 + Registry 预留;扫描与解析元数据;版本计算;配置与全局路径
93
93
 
94
- **原则:** store 按版本目录追加、不覆盖已有缓存;开发目录与项目安装目标分离;项目侧默认以软链引用资源,也支持复制。
94
+ **原则:** store 按版本目录追加、不覆盖已有缓存;开发目录与项目安装目标分离;项目侧默认复制资源,也支持软链引用。
95
95
 
96
96
  ### 3.2 目录与数据
97
97
 
package/docs/mvp/impl.md CHANGED
@@ -44,7 +44,7 @@
44
44
  - 命令层接受 `rule|command|skill`
45
45
  - 无版本则取该资源历史中的最新 semver
46
46
  - 若本地 store 已有该版本目录则不再从 Git 导出;否则从对应 tag 导出资源树到 store
47
- - 在项目中按 agent 和资源类型创建/更新安装目标,例如 `cursor -> .cursor/{rules|commands|skills}`、`codex -> .agents/{rules|commands|skills}`;默认软链到 store 中该版本,也可通过 `--mode copy` 复制内容
47
+ - 在项目中按 agent 和资源类型创建/更新安装目标,例如 `cursor -> .cursor/{rules|commands|skills}`、`codex -> .agents/{rules|commands|skills}`;默认复制 store 中该版本,也可通过 `--mode link` 软链引用
48
48
 
49
49
  ### 2.5 `dev <type> <name>`
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hi-man/himan",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Prompt and agent asset management CLI",
5
5
  "keywords": [
6
6
  "ai",
@@ -51,7 +51,7 @@
51
51
  "clean": "rm -rf dist",
52
52
  "prebuild": "rm -rf dist",
53
53
  "build": "tsc -p tsconfig.json",
54
- "dev": "tsx src/bin/himan.ts",
54
+ "cli": "tsx src/bin/himan.ts",
55
55
  "typecheck": "tsc -p tsconfig.json --noEmit",
56
56
  "test": "vitest run",
57
57
  "test:watch": "vitest",