flower-trellis 0.6.2 → 0.6.3

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.
Files changed (31) hide show
  1. package/package.json +3 -3
  2. package/src/builtin-marketplaces/rd-guide.json +6 -3
  3. package/src/builtin-plugins/flower-plugin-author/plugin.json +6 -1
  4. package/src/builtin-plugins/flower-plugin-author/skills/flower-plugin-author/references/ci-and-review.md +2 -2
  5. package/src/commands/plugin-interactive.js +546 -38
  6. package/src/commands/plugin-remote.js +156 -10
  7. package/src/commands/plugin.js +35 -4
  8. package/src/lib/skill-catalog.js +20 -1
  9. package/src/plugin/application-service.js +81 -7
  10. package/src/plugin/auth/credential-store.js +25 -3
  11. package/src/plugin/auth/gitlab-credential-resolver.js +234 -0
  12. package/src/plugin/auth/gitlab-oauth.js +26 -5
  13. package/src/plugin/authoring/scaffold.js +6 -1
  14. package/src/plugin/authoring/templates/rd-guide/CODEOWNERS +1 -1
  15. package/src/plugin/authoring/templates/rd-guide/gitlab-ci.yml +2 -2
  16. package/src/plugin/authoring/templates/rd-guide/verify-integration-review.mjs +1 -1
  17. package/src/plugin/content-selection.js +189 -0
  18. package/src/plugin/contracts.js +21 -2
  19. package/src/plugin/formats/adapters.js +27 -3
  20. package/src/plugin/formats/normalized-package.js +126 -14
  21. package/src/plugin/gitlab/rest-client.js +21 -0
  22. package/src/plugin/install/content-projector.js +19 -7
  23. package/src/plugin/resolver/dependency-resolver.js +6 -0
  24. package/src/plugin/runtime-errors.js +1 -0
  25. package/src/plugin/schemas/marketplace-manifest.js +4 -1
  26. package/src/plugin/schemas/plugin-manifest.js +55 -3
  27. package/src/plugin/schemas/project-files.js +18 -0
  28. package/src/plugin/schemas/shared.js +10 -0
  29. package/src/plugin/sources/github-provider.js +1 -1
  30. package/src/plugin/sources/gitlab-provider.js +377 -51
  31. package/src/plugin/sources/user-source-store.js +17 -1
@@ -15,18 +15,36 @@ function detection(values) {
15
15
  kind: values.kind,
16
16
  entryPath: values.entryPath,
17
17
  pluginRoot: values.pluginRoot,
18
+ manifestPath: values.manifestPath,
18
19
  displayName: values.displayName,
19
20
  manifest: values.manifest || null,
20
21
  };
21
22
  }
22
23
 
24
+ /**
25
+ * 判断 manifest 声明的内容路径是否在指定根目录下存在。
26
+ *
27
+ * @param {string} root 检测根
28
+ * @param {object} manifest Flower manifest
29
+ * @returns {boolean} 是否存在声明内容
30
+ */
31
+ function hasDeclaredContent(root, manifest) {
32
+ const content = manifest?.content || {};
33
+ const skillPaths = (content.skills || []).map((entry) => entry?.path).filter(Boolean);
34
+ const passivePaths = ["specs", "assets", "scripts", "tests"]
35
+ .flatMap((kind) => content[kind] || []);
36
+ return [...skillPaths, ...passivePaths].some((entry) => (
37
+ fs.existsSync(path.join(root, ...String(entry).split("/")))
38
+ ));
39
+ }
40
+
23
41
  /** Flower 原生格式 Adapter。 */
24
42
  export const flowerFormatAdapter = Object.freeze({
25
43
  format: "flower",
26
44
  /** @param {string} root 检测根 @returns {object[]} 检测结果 */
27
45
  detect(root) {
28
46
  const results = [];
29
- const marketplace = path.join(root, ".flower-marketplace", "marketplace.json");
47
+ const marketplace = path.join(root, ".flower-plugin", "marketplace.json");
30
48
  if (fs.existsSync(marketplace)) {
31
49
  const manifest = readFormatJson(marketplace, "Flower Marketplace");
32
50
  results.push(detection({
@@ -42,11 +60,16 @@ export const flowerFormatAdapter = Object.freeze({
42
60
  const manifestFile = path.join(pluginRoot, "plugin.json");
43
61
  if (!fs.existsSync(manifestFile)) continue;
44
62
  const manifest = readFormatJson(manifestFile, "Flower Plugin");
63
+ const metadataRoot = path.join(root, ".flower-plugin");
64
+ const rootLevelMetadata = pluginRoot === metadataRoot &&
65
+ hasDeclaredContent(root, manifest) &&
66
+ !hasDeclaredContent(metadataRoot, manifest);
45
67
  results.push(detection({
46
68
  format: "flower",
47
69
  kind: "plugin",
48
70
  entryPath: relativeEntryPath(root, manifestFile),
49
- pluginRoot,
71
+ pluginRoot: rootLevelMetadata ? root : pluginRoot,
72
+ manifestPath: rootLevelMetadata ? ".flower-plugin/plugin.json" : "plugin.json",
50
73
  displayName: manifest.name || manifest.id || path.basename(pluginRoot),
51
74
  manifest,
52
75
  }));
@@ -58,6 +81,7 @@ export const flowerFormatAdapter = Object.freeze({
58
81
  return normalizeFlowerPlugin({
59
82
  ...context,
60
83
  pluginRoot: selected.pluginRoot,
84
+ manifestPath: selected.manifestPath,
61
85
  });
62
86
  },
63
87
  });
@@ -159,7 +183,7 @@ export const skillOnlyFormatAdapter = Object.freeze({
159
183
  /** @param {string} root 检测根 @returns {object[]} 检测结果 */
160
184
  detect(root) {
161
185
  const knownEntries = [
162
- ".flower-marketplace/marketplace.json",
186
+ ".flower-plugin/marketplace.json",
163
187
  ".flower-plugin/plugin.json",
164
188
  "plugin.json",
165
189
  ".agents/plugins/marketplace.json",
@@ -26,6 +26,105 @@ const OMITTED_COMPONENTS = Object.freeze([
26
26
  ["apps", ".app.json"],
27
27
  ]);
28
28
 
29
+ /**
30
+ * 列出原生 Flower manifest 声明的包内容路径。
31
+ *
32
+ * @param {import("../contracts.js").PluginManifest} manifest Flower manifest
33
+ * @returns {string[]} 需要进入规范化包的相对路径
34
+ */
35
+ function declaredPackagePaths(manifest) {
36
+ const content = manifest.content || {};
37
+ const skills = (content.skills || []).map(({ path: skillPath }) => skillPath);
38
+ const passive = ["specs", "assets", "scripts", "tests"].flatMap((kind) => content[kind] || []);
39
+ const patches = [
40
+ manifest.patches?.catalog,
41
+ manifest.patches?.bundles,
42
+ ].filter(Boolean);
43
+ return [...new Set([...skills, ...passive, ...patches])].sort(compareUtf8);
44
+ }
45
+
46
+ /**
47
+ * 复制 manifest 显式声明的文件或目录。
48
+ *
49
+ * @param {string} sourceRoot 源仓库根
50
+ * @param {string} targetRoot 规范化包根
51
+ * @param {string} relative POSIX 相对路径
52
+ * @returns {void}
53
+ */
54
+ function copyDeclaredPath(sourceRoot, targetRoot, relative) {
55
+ const safeRelative = assertSafePosixRelativePath(relative, "Flower Plugin content 路径");
56
+ const source = path.join(sourceRoot, ...safeRelative.split("/"));
57
+ const target = path.join(targetRoot, ...safeRelative.split("/"));
58
+ let stat;
59
+ try {
60
+ stat = fs.lstatSync(source);
61
+ } catch (error) {
62
+ throw new PluginRuntimeError(`Flower Plugin 声明内容不存在:${safeRelative}`, {
63
+ code: PLUGIN_RUNTIME_ERROR_CODES.FORMAT_UNSUPPORTED,
64
+ path: safeRelative,
65
+ cause: error,
66
+ });
67
+ }
68
+ if (stat.isSymbolicLink()) {
69
+ throw new PluginRuntimeError(`Flower Plugin 声明内容不能是软链:${safeRelative}`, {
70
+ code: PLUGIN_RUNTIME_ERROR_CODES.FORMAT_UNSUPPORTED,
71
+ path: safeRelative,
72
+ });
73
+ }
74
+ if (stat.isDirectory()) {
75
+ for (const file of listCanonicalTreeFiles(source)) {
76
+ const destination = path.join(target, ...file.path.split("/"));
77
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
78
+ fs.copyFileSync(file.absolutePath, destination);
79
+ }
80
+ return;
81
+ }
82
+ if (!stat.isFile()) {
83
+ throw new PluginRuntimeError(`Flower Plugin 声明内容必须是普通文件或目录:${safeRelative}`, {
84
+ code: PLUGIN_RUNTIME_ERROR_CODES.FORMAT_UNSUPPORTED,
85
+ path: safeRelative,
86
+ });
87
+ }
88
+ fs.mkdirSync(path.dirname(target), { recursive: true });
89
+ fs.copyFileSync(source, target);
90
+ }
91
+
92
+ /**
93
+ * 复制与声明内容匹配的平台覆盖。
94
+ *
95
+ * @param {string} sourceRoot 源仓库根
96
+ * @param {string} targetRoot 规范化包根
97
+ * @param {import("../contracts.js").PluginManifest} manifest Flower manifest
98
+ * @returns {void}
99
+ */
100
+ function copyPlatformOverrides(sourceRoot, targetRoot, manifest) {
101
+ const platformsRoot = path.join(sourceRoot, "platforms");
102
+ if (!fs.existsSync(platformsRoot)) return;
103
+ for (const platform of fs.readdirSync(platformsRoot, { withFileTypes: true }).sort((left, right) => compareUtf8(left.name, right.name))) {
104
+ if (!platform.isDirectory()) continue;
105
+ for (const relative of declaredPackagePaths(manifest)) {
106
+ const override = `platforms/${platform.name}/${relative}`;
107
+ if (fs.existsSync(path.join(sourceRoot, ...override.split("/")))) {
108
+ copyDeclaredPath(sourceRoot, targetRoot, override);
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 展开 Flower manifest 声明内容为兼容性报告条目。
116
+ *
117
+ * @param {import("../contracts.js").PluginManifest} manifest Flower manifest
118
+ * @returns {Array<{kind:string,path:string,name?:string}>} 内容条目
119
+ */
120
+ function manifestContentItems(manifest) {
121
+ return Object.entries(manifest.content).flatMap(([kind, entries]) => (
122
+ kind === "skills"
123
+ ? entries.map((entry) => ({ kind, name: entry.name, path: entry.path }))
124
+ : entries.map((entry) => ({ kind, path: entry }))
125
+ )).sort((left, right) => compareUtf8(left.path, right.path));
126
+ }
127
+
29
128
  /**
30
129
  * 生成外部 Plugin 缺省内部版本。
31
130
  *
@@ -92,11 +191,15 @@ function copySkill(source, target, name, description) {
92
191
  /**
93
192
  * 复制并复核原生 Flower Plugin 包。
94
193
  *
95
- * @param {{pluginRoot:string,outputRoot:string}} options 规范化参数
194
+ * @param {{pluginRoot:string,outputRoot:string,manifestPath?:string}} options 规范化参数
96
195
  * @returns {{root:string,manifest:import("../contracts.js").PluginManifest,integrity:string,compatibilityReport:object,externalVersion:string,description:string}} 标准包
97
196
  */
98
197
  export function normalizeFlowerPlugin(options) {
99
198
  const packageRoot = path.resolve(options.outputRoot);
199
+ const manifestPath = assertSafePosixRelativePath(
200
+ options.manifestPath || "plugin.json",
201
+ "Flower Plugin manifest 路径",
202
+ );
100
203
  if (fs.existsSync(packageRoot)) {
101
204
  throw new PluginRuntimeError(`规范化包目标已存在:${packageRoot}`, {
102
205
  code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
@@ -105,14 +208,23 @@ export function normalizeFlowerPlugin(options) {
105
208
  }
106
209
  fs.mkdirSync(packageRoot, { recursive: true });
107
210
  try {
108
- for (const file of listCanonicalTreeFiles(options.pluginRoot)) {
109
- const destination = path.join(packageRoot, ...file.path.split("/"));
110
- fs.mkdirSync(path.dirname(destination), { recursive: true });
111
- fs.copyFileSync(file.absolutePath, destination);
112
- }
211
+ const raw = fs.readFileSync(path.join(options.pluginRoot, ...manifestPath.split("/")), "utf8");
113
212
  const manifest = validatePluginManifest(JSON.parse(
114
- fs.readFileSync(path.join(packageRoot, "plugin.json"), "utf8"),
213
+ raw,
115
214
  ));
215
+ if (manifestPath === "plugin.json") {
216
+ for (const file of listCanonicalTreeFiles(options.pluginRoot)) {
217
+ const destination = path.join(packageRoot, ...file.path.split("/"));
218
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
219
+ fs.copyFileSync(file.absolutePath, destination);
220
+ }
221
+ } else {
222
+ fs.writeFileSync(path.join(packageRoot, "plugin.json"), raw);
223
+ for (const relative of declaredPackagePaths(manifest)) {
224
+ copyDeclaredPath(options.pluginRoot, packageRoot, relative);
225
+ }
226
+ copyPlatformOverrides(options.pluginRoot, packageRoot, manifest);
227
+ }
116
228
  return {
117
229
  root: packageRoot,
118
230
  manifest,
@@ -120,9 +232,7 @@ export function normalizeFlowerPlugin(options) {
120
232
  compatibilityReport: {
121
233
  status: "compatible",
122
234
  format: "flower",
123
- imported: Object.entries(manifest.content).flatMap(([kind, paths]) => (
124
- paths.map((value) => ({ kind, path: value }))
125
- )).sort((left, right) => compareUtf8(left.path, right.path)),
235
+ imported: manifestContentItems(manifest),
126
236
  omitted: [],
127
237
  diagnostics: [],
128
238
  },
@@ -206,12 +316,12 @@ export function normalizeExternalPlugin(options) {
206
316
  if (!fs.existsSync(path.join(source, "SKILL.md"))) continue;
207
317
  const name = registerName(directory, "Skill");
208
318
  copySkill(source, path.join(packageRoot, "skills", name), name, `${pluginName} 的 ${name} 工作流`);
209
- imported.push({ kind: "skills", path: `skills/${name}` });
319
+ imported.push({ kind: "skills", name, path: `skills/${name}` });
210
320
  }
211
321
  } else if (fs.existsSync(path.join(root, "SKILL.md"))) {
212
322
  const name = registerName(path.basename(root), "Skill");
213
323
  copySkill(root, path.join(packageRoot, "skills", name), name, `${pluginName} 的 ${name} 工作流`);
214
- imported.push({ kind: "skills", path: `skills/${name}` });
324
+ imported.push({ kind: "skills", name, path: `skills/${name}` });
215
325
  }
216
326
  }
217
327
 
@@ -226,7 +336,7 @@ export function normalizeExternalPlugin(options) {
226
336
  path.join(packageRoot, "skills", name, "SKILL.md"),
227
337
  normalizedSkillContent(content, name, `${pluginName} 的 ${name} 命令工作流`),
228
338
  );
229
- imported.push({ kind: "commands", path: `skills/${name}` });
339
+ imported.push({ kind: "commands", name, path: `skills/${name}` });
230
340
  diagnostics.push({
231
341
  code: "external.command-converted",
232
342
  path: `commands/${file}`,
@@ -260,7 +370,9 @@ export function normalizeExternalPlugin(options) {
260
370
  dependencies: {},
261
371
  capabilities: { profile: "standard", required: ["content.skills"] },
262
372
  content: {
263
- skills: imported.map(({ path: value }) => value).sort(compareUtf8),
373
+ skills: imported
374
+ .map(({ name, path: value }) => ({ name, path: value, version }))
375
+ .sort((left, right) => compareUtf8(left.name, right.name)),
264
376
  },
265
377
  });
266
378
  fs.writeFileSync(path.join(packageRoot, "plugin.json"), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -216,6 +216,18 @@ export class GitLabRestClient {
216
216
  { status: response.status, endpoint: pathname, locationOrigin },
217
217
  );
218
218
  }
219
+ if (response.status === 401) {
220
+ throw this.#authError(`GitLab REST 认证失败,请重新登录:${this.source.id}`, PLUGIN_RUNTIME_ERROR_CODES.AUTH_REQUIRED, {
221
+ status: response.status,
222
+ endpoint: pathname,
223
+ });
224
+ }
225
+ if (response.status === 403) {
226
+ throw this.#authError(`GitLab REST scope 不足或访问被拒绝:${this.source.id}`, PLUGIN_RUNTIME_ERROR_CODES.AUTH_SCOPE_INVALID, {
227
+ status: response.status,
228
+ endpoint: pathname,
229
+ });
230
+ }
219
231
  throw this.#remoteError(`GitLab REST 请求失败:${response.status}`, this.source.id, undefined, {
220
232
  status: response.status,
221
233
  endpoint: pathname,
@@ -240,4 +252,13 @@ export class GitLabRestClient {
240
252
  details,
241
253
  });
242
254
  }
255
+
256
+ /** @param {string} message @param {string} code @param {object} details @returns {PluginRuntimeError} */
257
+ #authError(message, code, details) {
258
+ return new PluginRuntimeError(message, {
259
+ code,
260
+ path: this.source.id,
261
+ details,
262
+ });
263
+ }
243
264
  }
@@ -9,6 +9,10 @@ import {
9
9
  import { assertSafePosixRelativePath } from "../schemas/shared.js";
10
10
  import { compareUtf8 } from "../stable-order.js";
11
11
  import { isRuntimeBuiltinProviderTrusted } from "../runtime-extensions.js";
12
+ import {
13
+ normalizeContentSelection,
14
+ selectContentSkillEntries,
15
+ } from "../content-selection.js";
12
16
  import { hashContent, hashFileIfExists } from "./content-hash.js";
13
17
 
14
18
  const CONTENT_KINDS = ["skills", "specs", "assets", "scripts", "tests"];
@@ -99,10 +103,11 @@ function resolvePlatformContent(packageRoot, platform, entry, relative, canonica
99
103
  * @param {string} kind 内容类型
100
104
  * @param {string} entry manifest 内容路径
101
105
  * @param {string|null} skillRoot 平台 Skill root
106
+ * @param {string} [entryName] 内容目标名称
102
107
  * @returns {string} 项目内目标前缀
103
108
  */
104
- function targetPrefix(owner, kind, entry, skillRoot) {
105
- const name = path.posix.basename(entry);
109
+ function targetPrefix(owner, kind, entry, skillRoot, entryName = path.posix.basename(entry)) {
110
+ const name = entryName;
106
111
  if (kind === "skills") return `${skillRoot}/${name}`;
107
112
  return `.flower/content/${owner}/${kind}/${name}`;
108
113
  }
@@ -146,6 +151,7 @@ export function projectPluginContent(options) {
146
151
  const candidate = selectedById.get(resolved.id);
147
152
  if (!candidate) throw new Error(`Resolved Plugin 缺少候选:${resolved.id}`);
148
153
  const pluginPackage = options.registry.readPackage(candidate);
154
+ const contentSelection = normalizeContentSelection(resolved.contentSelection);
149
155
  const provider = typeof options.registry.get === "function"
150
156
  ? options.registry.get(candidate.source.id)
151
157
  : null;
@@ -184,11 +190,16 @@ export function projectPluginContent(options) {
184
190
  }
185
191
  const paths = new Map();
186
192
  for (const kind of CONTENT_KINDS) {
187
- for (const entry of [...(pluginPackage.manifest.content[kind] || [])].sort(compareUtf8)) {
188
- const expanded = expandContentEntry(pluginPackage.root, entry);
193
+ const entries = kind === "skills"
194
+ ? selectContentSkillEntries(pluginPackage.manifest.content.skills || [], contentSelection, resolved.id)
195
+ : [...(pluginPackage.manifest.content[kind] || [])].sort(compareUtf8);
196
+ for (const entry of entries) {
197
+ const contentPath = kind === "skills" ? entry.path : entry;
198
+ const contentName = kind === "skills" ? entry.name : undefined;
199
+ const expanded = expandContentEntry(pluginPackage.root, contentPath);
189
200
  const targets = kind === "skills" ? options.platformSelection.targets : [{ root: null }];
190
201
  for (const platformTarget of targets) {
191
- const prefix = targetPrefix(resolved.id, kind, entry, platformTarget.root);
202
+ const prefix = targetPrefix(resolved.id, kind, contentPath, platformTarget.root, contentName);
192
203
  if (kind !== "skills") {
193
204
  addDirectoryClaims(directoryClaims, resolved.id, `.flower/content/${resolved.id}`, "");
194
205
  addDirectoryClaims(directoryClaims, resolved.id, `.flower/content/${resolved.id}/${kind}`, "");
@@ -212,9 +223,9 @@ export function projectPluginContent(options) {
212
223
  const canonical = fs.readFileSync(file.absolutePath);
213
224
  const platformContents = kind === "skills"
214
225
  ? platformTarget.platforms.map((platform) => (
215
- resolvePlatformContent(pluginPackage.root, platform, entry, file.relative, canonical)
226
+ resolvePlatformContent(pluginPackage.root, platform, contentPath, file.relative, canonical)
216
227
  ))
217
- : [{ content: canonical, source: `${entry}${file.relative ? `/${file.relative}` : ""}` }];
228
+ : [{ content: canonical, source: `${contentPath}${file.relative ? `/${file.relative}` : ""}` }];
218
229
  const contentHashes = new Set(platformContents.map(({ content }) => hashContent(content)));
219
230
  if (contentHashes.size > 1) {
220
231
  throw new PluginRuntimeError(`共享 Skill root 的 platform override 内容不一致:${target}`, {
@@ -257,6 +268,7 @@ export function projectPluginContent(options) {
257
268
  id: resolved.id,
258
269
  version: resolved.version,
259
270
  platforms: [...options.platformSelection.platforms],
271
+ ...(contentSelection ? { contentSelection } : {}),
260
272
  paths: [...paths.values()].sort((left, right) => compareUtf8(left.path, right.path)),
261
273
  patches: [],
262
274
  });
@@ -4,6 +4,7 @@ import {
4
4
  PluginRuntimeError,
5
5
  } from "../runtime-errors.js";
6
6
  import { compareUtf8 } from "../stable-order.js";
7
+ import { normalizeContentSelection } from "../content-selection.js";
7
8
 
8
9
  /**
9
10
  * 比较候选的稳定优先级。
@@ -173,6 +174,7 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
173
174
  : new Set(options.update || []);
174
175
  const grantCapabilities = options.grantCapabilities || defaultCapabilityGrant;
175
176
  const initialConstraints = new Map();
177
+ const rootDeclarations = new Map(declarations.map((declaration) => [declaration.id, declaration]));
176
178
 
177
179
  for (const declaration of [...declarations].sort((left, right) => compareUtf8(left.id, right.id))) {
178
180
  const entries = initialConstraints.get(declaration.id) || [];
@@ -279,6 +281,9 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
279
281
  const order = stableTopologicalOrder(roots, selected);
280
282
  const resolvedPlugins = order.map((id) => {
281
283
  const candidate = selected.get(id);
284
+ const contentSelection = rootDeclarations.has(id)
285
+ ? normalizeContentSelection(rootDeclarations.get(id).contentSelection)
286
+ : undefined;
282
287
  const dependencies = Object.fromEntries(
283
288
  Object.keys(candidate.manifest.dependencies || {}).sort(compareUtf8).map((dependency) => [
284
289
  dependency,
@@ -294,6 +299,7 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
294
299
  dependencies,
295
300
  compatibility: candidate.manifest.compatibility,
296
301
  capabilities: grantCapabilities(candidate.manifest.capabilities, candidate),
302
+ ...(contentSelection ? { contentSelection } : {}),
297
303
  };
298
304
  });
299
305
  const graph = { roots, plugins: resolvedPlugins };
@@ -20,6 +20,7 @@ export const PLUGIN_RUNTIME_ERROR_CODES = Object.freeze({
20
20
  DEPENDENCY_CYCLE: "PLUGIN_DEPENDENCY_CYCLE",
21
21
  PLATFORM_SELECTION_REQUIRED: "PLUGIN_PLATFORM_SELECTION_REQUIRED",
22
22
  PLATFORM_UNKNOWN: "PLUGIN_PLATFORM_UNKNOWN",
23
+ CONTENT_SELECTION_INVALID: "PLUGIN_CONTENT_SELECTION_INVALID",
23
24
  CONTENT_CONFLICT: "PLUGIN_CONTENT_CONFLICT",
24
25
  TARGET_DRIFT: "PLUGIN_TARGET_DRIFT",
25
26
  TRANSACTION_FAILED: "PLUGIN_TRANSACTION_FAILED",
@@ -6,10 +6,11 @@ const sourceSchema = {
6
6
  {
7
7
  type: "object",
8
8
  additionalProperties: false,
9
- required: ["type", "path"],
9
+ required: ["type"],
10
10
  properties: {
11
11
  type: { const: "path" },
12
12
  path: SAFE_PATH_SCHEMA,
13
+ manifestPath: SAFE_PATH_SCHEMA,
13
14
  },
14
15
  },
15
16
  {
@@ -20,6 +21,7 @@ const sourceSchema = {
20
21
  type: { const: "gitlab" },
21
22
  project: { type: "string", format: "gitlab-project" },
22
23
  subdir: SAFE_PATH_SCHEMA,
24
+ manifestPath: SAFE_PATH_SCHEMA,
23
25
  },
24
26
  },
25
27
  {
@@ -30,6 +32,7 @@ const sourceSchema = {
30
32
  type: { const: "github" },
31
33
  repository: { type: "string", format: "github-repository" },
32
34
  subdir: SAFE_PATH_SCHEMA,
35
+ manifestPath: SAFE_PATH_SCHEMA,
33
36
  },
34
37
  },
35
38
  ],
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  CAPABILITY_NAME_SCHEMA,
3
+ CONTENT_SKILL_NAME_SCHEMA,
3
4
  PLUGIN_SCHEMA_VERSION,
4
5
  SAFE_PATH_SCHEMA,
5
6
  } from "./shared.js";
6
- import { createSchemaValidator } from "./validator.js";
7
+ import { createSchemaValidator, schemaIssue } from "./validator.js";
7
8
 
8
9
  const stringList = {
9
10
  type: "array",
@@ -11,6 +12,21 @@ const stringList = {
11
12
  uniqueItems: true,
12
13
  };
13
14
 
15
+ const skillEntryList = {
16
+ type: "array",
17
+ items: {
18
+ type: "object",
19
+ additionalProperties: false,
20
+ required: ["name", "path", "version"],
21
+ properties: {
22
+ name: CONTENT_SKILL_NAME_SCHEMA,
23
+ path: SAFE_PATH_SCHEMA,
24
+ version: { type: "string", format: "semver" },
25
+ description: { type: "string", minLength: 1 },
26
+ },
27
+ },
28
+ };
29
+
14
30
  /** Flower Plugin Manifest v1 JSON Schema。 */
15
31
  export const PLUGIN_MANIFEST_SCHEMA = Object.freeze({
16
32
  $id: "https://flower-trellis.local/schema/plugin-manifest-v1.json",
@@ -59,7 +75,7 @@ export const PLUGIN_MANIFEST_SCHEMA = Object.freeze({
59
75
  additionalProperties: false,
60
76
  minProperties: 1,
61
77
  properties: {
62
- skills: stringList,
78
+ skills: skillEntryList,
63
79
  specs: stringList,
64
80
  assets: stringList,
65
81
  scripts: stringList,
@@ -78,7 +94,43 @@ export const PLUGIN_MANIFEST_SCHEMA = Object.freeze({
78
94
  },
79
95
  });
80
96
 
81
- const validate = createSchemaValidator(PLUGIN_MANIFEST_SCHEMA, "Flower Plugin manifest");
97
+ /**
98
+ * 校验 manifest 中 Skill 条目的唯一性。
99
+ *
100
+ * @param {unknown} value 已通过结构校验的 manifest
101
+ * @returns {Array<{code:string,path:string,message:string}>} 语义 issue
102
+ */
103
+ function pluginManifestIssues(value) {
104
+ const manifest = /** @type {{content?:{skills?:Array<{name:string,path:string}>}}} */ (value);
105
+ const issues = [];
106
+ const names = new Set();
107
+ const paths = new Set();
108
+ for (const [index, skill] of (manifest.content?.skills || []).entries()) {
109
+ if (names.has(skill.name)) {
110
+ issues.push(schemaIssue(
111
+ "manifest.duplicate-skill-name",
112
+ `/content/skills/${index}/name`,
113
+ `Skill 名称重复:${skill.name}`,
114
+ ));
115
+ }
116
+ names.add(skill.name);
117
+ if (paths.has(skill.path)) {
118
+ issues.push(schemaIssue(
119
+ "manifest.duplicate-skill-path",
120
+ `/content/skills/${index}/path`,
121
+ `Skill 路径重复:${skill.path}`,
122
+ ));
123
+ }
124
+ paths.add(skill.path);
125
+ }
126
+ return issues;
127
+ }
128
+
129
+ const validate = createSchemaValidator(
130
+ PLUGIN_MANIFEST_SCHEMA,
131
+ "Flower Plugin manifest",
132
+ pluginManifestIssues,
133
+ );
82
134
 
83
135
  /**
84
136
  * 校验 Flower Plugin Manifest v1。
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  CAPABILITY_NAME_SCHEMA,
3
+ CONTENT_SKILL_NAME_SCHEMA,
3
4
  PLUGIN_SCHEMA_VERSION,
4
5
  SAFE_PATH_SCHEMA,
5
6
  parseCanonicalPluginId,
@@ -69,6 +70,20 @@ const capabilityGrantSchema = {
69
70
  },
70
71
  };
71
72
 
73
+ const contentSelectionSchema = {
74
+ type: "object",
75
+ additionalProperties: false,
76
+ required: ["skills"],
77
+ properties: {
78
+ skills: {
79
+ type: "array",
80
+ minItems: 1,
81
+ items: CONTENT_SKILL_NAME_SCHEMA,
82
+ uniqueItems: true,
83
+ },
84
+ },
85
+ };
86
+
72
87
  /** `.flower/plugins.json` v1 JSON Schema。 */
73
88
  export const PLUGINS_FILE_SCHEMA = Object.freeze({
74
89
  $id: "https://flower-trellis.local/schema/plugins-file-v1.json",
@@ -92,6 +107,7 @@ export const PLUGINS_FILE_SCHEMA = Object.freeze({
92
107
  items: { type: "string", format: "plugin-id" },
93
108
  uniqueItems: true,
94
109
  },
110
+ contentSelection: contentSelectionSchema,
95
111
  },
96
112
  },
97
113
  },
@@ -147,6 +163,7 @@ export const PLUGIN_LOCK_SCHEMA = Object.freeze({
147
163
  },
148
164
  },
149
165
  capabilities: capabilityGrantSchema,
166
+ contentSelection: contentSelectionSchema,
150
167
  },
151
168
  },
152
169
  },
@@ -176,6 +193,7 @@ export const PLUGIN_STATE_SCHEMA = Object.freeze({
176
193
  items: { type: "string", format: "plugin-id" },
177
194
  uniqueItems: true,
178
195
  },
196
+ contentSelection: contentSelectionSchema,
179
197
  paths: {
180
198
  type: "array",
181
199
  items: {
@@ -12,6 +12,9 @@ export const PLUGIN_ID_PATTERN = "^[a-z0-9]+(?:-[a-z0-9]+)*$";
12
12
  export const CANONICAL_PLUGIN_ID_PATTERN =
13
13
  "^[a-z0-9]+(?:-[a-z0-9]+)*/[a-z0-9]+(?:-[a-z0-9]+)*$";
14
14
 
15
+ /** contentSelection.skills 与 manifest Skill 条目名称的正则源。 */
16
+ export const CONTENT_SKILL_NAME_PATTERN = "^(?!\\.{1,2}$)[^/\\\\]+$";
17
+
15
18
  /** SHA-256 摘要的正则源。 */
16
19
  export const SHA256_DIGEST_PATTERN = "^sha256:[a-f0-9]{64}$";
17
20
 
@@ -180,6 +183,13 @@ export function isGitHubRepository(value) {
180
183
  /** 可复用的安全相对路径 JSON Schema。 */
181
184
  export const SAFE_PATH_SCHEMA = Object.freeze({ type: "string", format: "posix-relative-path" });
182
185
 
186
+ /** 可复用的内容 Skill 名称 JSON Schema。 */
187
+ export const CONTENT_SKILL_NAME_SCHEMA = Object.freeze({
188
+ type: "string",
189
+ minLength: 1,
190
+ pattern: CONTENT_SKILL_NAME_PATTERN,
191
+ });
192
+
183
193
  /** 可复用的 capability 名称 JSON Schema。 */
184
194
  export const CAPABILITY_NAME_SCHEMA = Object.freeze({
185
195
  type: "string",
@@ -433,7 +433,7 @@ export class GitHubSourceProvider {
433
433
  committedAt: resolved.committedAt,
434
434
  subdir,
435
435
  format: "flower",
436
- entryPath: null,
436
+ entryPath: entry.source.manifestPath || null,
437
437
  });
438
438
  if (prepared.packages.length !== 1) {
439
439
  throw new PluginRuntimeError(`Flower Marketplace 条目不是单一 Plugin:${entry.id}`, {