@hi-man/himan 0.3.5 → 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 +9 -0
- package/README.md +40 -7
- package/dist/adapters/git/repo-manager.js +246 -0
- package/dist/adapters/resource/resource-analysis.js +42 -0
- package/dist/adapters/source/git-source-adapter.js +188 -9
- package/dist/adapters/source/registry-source-adapter.js +3 -0
- package/dist/cli/builders.js +4 -3
- package/dist/cli/resource-commands.js +23 -0
- package/dist/cli/source-commands.js +74 -0
- package/dist/domain/source-transfer.js +1 -0
- package/dist/services/index.js +259 -3
- package/dist/state/project-lock-store.js +19 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,15 @@ 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
|
+
|
|
9
18
|
## [0.3.5] - 2026-05-12
|
|
10
19
|
|
|
11
20
|
### 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>`
|
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/cli/builders.js
CHANGED
|
@@ -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,
|
|
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) {
|
|
@@ -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 {};
|
package/dist/services/index.js
CHANGED
|
@@ -43,9 +43,7 @@ export class ServiceFactory {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
async addSource(name, type, repo) {
|
|
46
|
-
|
|
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") {
|
|
@@ -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 };
|
|
@@ -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
|
}
|