@seeed-studio/solution-cli 0.4.0 → 0.4.2

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/docs/cli-guide.md CHANGED
@@ -28,24 +28,26 @@ Agent 的默认工作流是“先咨询,再选型,再检查,最后在用
28
28
 
29
29
  ## 2. 安装与 Agent 技能挂载
30
30
 
31
- ### 2.1 按需精准安装(推荐:只针对当前 Agent 安装,避免环境污染)
31
+ ### 2.1 按需精准安装(推荐:npm 全局安装并挂载,避免 PATH 缺失报错)
32
32
 
33
33
  你可以直接复制下表中对应的专属命令发给对应的 AI 助手,或在终端执行:
34
34
 
35
35
  | 目标 AI 宿主平台 | 专属安装命令(直接复制发给 AI 或在终端运行) | 技能默认配置路径 |
36
36
  |---|---|---|
37
- | **豆包工作 (Doubao Work)** | `npx -y @seeed-studio/solution-cli install --agent doubao` | `~/.doubao/skills` |
38
- | **WorkBuddy** | `npx -y @seeed-studio/solution-cli install --agent workbuddy` | `~/.workbuddy/skills` |
39
- | **Claude Code** | `npx -y @seeed-studio/solution-cli install --agent claude` | `~/.claude/skills` |
40
- | **OpenAI Codex** | `npx -y @seeed-studio/solution-cli install --agent codex` | `~/.agents/skills` 或 `~/.codex/skills` |
41
- | **Google Antigravity** | `npx -y @seeed-studio/solution-cli install --agent antigravity` | `~/.gemini/config/skills` |
42
- | **CodeBuddy** | `npx -y @seeed-studio/solution-cli install --agent codebuddy` | `~/.codebuddy/skills` |
43
- | **全部 Agent (全量覆盖)** | `npx -y @seeed-studio/solution-cli install --agent all` | 上述全部已检测目录 |
44
- | **自定义目录 (如 Cursor)** | `npx -y @seeed-studio/solution-cli install --skills-dir <路径>` | `<自定义路径>/solution-cli` |
37
+ | **豆包工作 (Doubao Work)** | `npm install -g @seeed-studio/solution-cli && solution-cli install --agent doubao` | `~/Doubao/skills` (及 `~/.agents/skills`) |
38
+ | **Claude Code** | `npm install -g @seeed-studio/solution-cli && solution-cli install --agent claude` | `~/.claude/skills` |
39
+ | **OpenAI Codex** | `npm install -g @seeed-studio/solution-cli && solution-cli install --agent codex` | `~/.agents/skills` 或 `~/.codex/skills` |
40
+ | **Google Antigravity** | `npm install -g @seeed-studio/solution-cli && solution-cli install --agent antigravity` | `~/.gemini/config/skills` |
41
+ | **WorkBuddy** | `npm install -g @seeed-studio/solution-cli && solution-cli install --agent workbuddy` | `~/.workbuddy/skills` |
42
+ | **CodeBuddy** | `npm install -g @seeed-studio/solution-cli && solution-cli install --agent codebuddy` | `~/.codebuddy/skills` |
43
+ | **Hermes Agent** | `npm install -g @seeed-studio/solution-cli && solution-cli install --agent hermes` | `$HERMES_HOME/skills` (默认 `~/.hermes/skills`) |
44
+ | **全部 Agent (全量覆盖)** | `npm install -g @seeed-studio/solution-cli && solution-cli install --agent all` | 上述全部已检测目录 |
45
+ | **自定义目录 (如 Cursor)** | `npm install -g @seeed-studio/solution-cli && solution-cli install --skills-dir <路径>` | `<自定义路径>/solution-cli` |
45
46
 
46
47
  > [!TIP]
47
- > - **丢给 AI 极简执行**:例如在豆包工作中直接发送:*“请帮我运行:`npx -y @seeed-studio/solution-cli install --agent doubao`”*,AI 将直接在后台非交互执行并自动就绪。
48
- > - **严格隔离**:指定 `--agent <id>` 时,安装器依据 ledger 记录仅写入该 Agent 对应的目录,绝不修改其他 Agent 的技能或配置。
48
+ > - **为什么推荐 npm 全局安装?** 将 `solution-cli` 加入全局 PATH,AI Agent 在后续对话中调用方案/硬件查询时直接本地毫秒级响应,避免因 PATH 缺失触发 `command not found`。
49
+ > - **发给 AI 助手**:例如在豆包工作中直接发送:*“请帮我运行:`npm install -g @seeed-studio/solution-cli && solution-cli install --agent doubao`”*。
50
+ > - **免全局安装(npx 备选)**:如无全局 npm 写入权限,可使用 npx:`npx -y @seeed-studio/solution-cli install --agent <id>`。
49
51
 
50
52
  ### 2.2 本地源码部署与定制开发 (Local Deployment)
51
53
 
@@ -59,6 +61,27 @@ node bin/cli.js install --agent <doubao|claude|codex|antigravity|codebuddy>
59
61
  node bin/cli.js install --skills-dir ~/.cursor/skills
60
62
  ```
61
63
 
64
+ 省略 `--agent` 时,终端交互运行会进入选择器;非交互运行会安装到已检测的宿主,未检测到宿主时会选择全部宿主。建议始终明确指定当前 Agent。重复执行会复用相同或更新的签名 Engine、升级旧版本,并保留用户修改的 Skill。`--force` 会覆盖已有 Skill 修改,只在用户明确要求时使用。
65
+
66
+ 安装完成后刷新宿主的 Skill 列表或完全退出并重新打开 Agent 应用,确认宿主的实际 Skill 列表中已出现 `solution-cli`,再新建会话直接描述需求。若宿主未自动发现技能,发送:“请使用 solution-cli Skill,先查询方案,再告诉我缺哪些部署信息。”安装器只写入被选中的宿主。
67
+
68
+ ### 2.3 全局安装与 Profile
69
+
70
+ 需要长期使用 `solution-cli` 命令时可以全局安装:
71
+
72
+ ```bash
73
+ npm install -g --registry=https://registry.npmjs.org/ @seeed-studio/solution-cli@latest
74
+ solution-cli status
75
+ ```
76
+
77
+ 不想修改全局 npm 环境时,使用 `npx --package` 临时调用:
78
+
79
+ ```bash
80
+ npx -y --registry=https://registry.npmjs.org/ --package=@seeed-studio/solution-cli@latest solution-cli hub search "农业" --json
81
+ ```
82
+
83
+ Hermes 的已配置 profile 可通过 `--agent hermes:<profile>` 选择(安装、更新、卸载通用):具名 profile 使用 `<root>/profiles/<profile>/skills/solution-cli`,`hermes:default` 使用 `<root>/skills/solution-cli`;`<root>` 是 Hermes 安装根目录。Profile ID 为 1–64 位小写字母、数字、`_` 或 `-`,以字母或数字开头。安装器仅复制 Skill,不创建或切换 profile。
84
+
62
85
  ## 3. 核心功能、使用方法与具体产出
63
86
 
64
87
  安装后可直接在命令行运行 `solution-cli` 与 `sensecraft-engine`;若未执行安装,也可随时通过 `npx -y @seeed-studio/solution-cli <command>` 免安装即用。
package/lib/agent.js CHANGED
@@ -5,6 +5,53 @@ import readline from 'node:readline';
5
5
  import crypto from 'node:crypto';
6
6
  import { loadLedger } from './ledger.js';
7
7
 
8
+ // Match Hermes' native data home and get_default_hermes_root() semantics.
9
+ function getHermesPaths() {
10
+ const home = os.homedir();
11
+ const suffix = process.env.HERMES_DATA_DIR_SUFFIX || '';
12
+ const nativeHome = process.platform === 'win32'
13
+ ? path.join(process.env.LOCALAPPDATA?.trim() || path.join(home, 'AppData', 'Local'), `hermes${suffix}`)
14
+ : path.join(home, `.hermes${suffix}`);
15
+ const expand = value => value.replace(/\$(\w+)|\$\{([^}]+)\}/g,
16
+ (match, bare, braced) => process.env[bare || braced] ?? match)
17
+ .replace(/^~(?=$|[\\/])/, () => home);
18
+ const activeHome = path.resolve(expand(process.env.HERMES_HOME?.trim() || nativeHome));
19
+ const real = value => fs.existsSync(value) ? fs.realpathSync(value) : path.resolve(value);
20
+ const relative = path.relative(real(nativeHome), real(activeHome));
21
+ const inNativeHome = relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
22
+ const root = inNativeHome ? nativeHome
23
+ : path.basename(path.dirname(activeHome)) === 'profiles' ? path.dirname(path.dirname(activeHome)) : activeHome;
24
+ return { activeHome, root };
25
+ }
26
+
27
+ function hermesTarget(id = 'hermes') {
28
+ const { activeHome, root } = getHermesPaths();
29
+ let targetHome = activeHome;
30
+ if (id !== 'hermes') {
31
+ const profile = id.slice('hermes:'.length);
32
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile)) {
33
+ throw new Error(`Invalid Hermes profile: "${profile}". Use 1-64 lowercase letters, numbers, '-' or '_', starting with a letter or number.`);
34
+ }
35
+ targetHome = profile === 'default' ? root : path.join(root, 'profiles', profile);
36
+ }
37
+ return {
38
+ id,
39
+ name: id === 'hermes' ? 'Hermes Agent' : `Hermes Agent (${id.slice(7)})`,
40
+ homeDirs: [targetHome],
41
+ getSkillsDir: () => path.join(targetHome, 'skills'),
42
+ };
43
+ }
44
+
45
+ function scanAgent(agent) {
46
+ const skillsDir = agent.getSkillsDir();
47
+ return {
48
+ ...agent,
49
+ detected: agent.homeDirs.some(dir => fs.existsSync(dir)) || fs.existsSync(skillsDir),
50
+ installed: fs.existsSync(path.join(skillsDir, 'solution-cli', 'SKILL.md')),
51
+ skillsDir,
52
+ };
53
+ }
54
+
8
55
  export function getKnownAgents() {
9
56
  const home = os.homedir();
10
57
 
@@ -12,8 +59,19 @@ export function getKnownAgents() {
12
59
  {
13
60
  id: 'doubao',
14
61
  name: '豆包工作 (Doubao Work)',
15
- homeDirs: [path.join(home, '.doubao')],
16
- getSkillsDir: () => path.join(home, '.doubao', 'skills')
62
+ homeDirs: [path.join(home, 'Doubao'), path.join(home, '.doubao')],
63
+ getSkillsDir: () => {
64
+ const pDoubao = path.join(home, 'Doubao', 'skills');
65
+ const pAgents = path.join(home, '.agents', 'skills');
66
+ const pLegacy = path.join(home, '.doubao', 'skills');
67
+ if (fs.existsSync(path.join(pDoubao, 'solution-cli', 'SKILL.md'))) return pDoubao;
68
+ if (fs.existsSync(path.join(pAgents, 'solution-cli', 'SKILL.md'))) return pAgents;
69
+ if (fs.existsSync(path.join(pLegacy, 'solution-cli', 'SKILL.md'))) return pLegacy;
70
+ if (fs.existsSync(path.join(home, 'Doubao'))) return pDoubao;
71
+ if (fs.existsSync(path.join(home, '.agents'))) return pAgents;
72
+ if (fs.existsSync(path.join(home, '.doubao'))) return pLegacy;
73
+ return pDoubao;
74
+ }
17
75
  },
18
76
  {
19
77
  id: 'workbuddy',
@@ -68,7 +126,8 @@ export function getKnownAgents() {
68
126
  name: 'Google Antigravity (AGY)',
69
127
  homeDirs: [path.join(home, '.gemini', 'config'), path.join(home, '.gemini', 'antigravity-cli')],
70
128
  getSkillsDir: () => path.join(home, '.gemini', 'config', 'skills')
71
- }
129
+ },
130
+ hermesTarget()
72
131
  ];
73
132
  }
74
133
 
@@ -78,6 +137,10 @@ export function scanLocalAgents() {
78
137
  try {
79
138
  const ledger = loadLedger();
80
139
  for (const [targetId, inst] of Object.entries(ledger?.installations || {})) {
140
+ if (targetId.startsWith('hermes:') && inst && !agents.some(a => a.id === targetId)) {
141
+ const target = hermesTarget(targetId);
142
+ if (fs.existsSync(path.join(target.getSkillsDir(), 'solution-cli', 'SKILL.md'))) agents.push(target);
143
+ }
81
144
  if (targetId.startsWith('custom_') && inst) {
82
145
  const skillsDir = inst.skills_dir || (inst.target_dir ? path.dirname(inst.target_dir) : (inst.targetDir ? path.dirname(inst.targetDir) : null));
83
146
  if (skillsDir && fs.existsSync(skillsDir) && !agents.some(a => a.id === targetId)) {
@@ -92,17 +155,7 @@ export function scanLocalAgents() {
92
155
  }
93
156
  } catch {}
94
157
 
95
- return agents.map(a => {
96
- const skillsDir = a.getSkillsDir();
97
- const detected = a.homeDirs.some(dir => fs.existsSync(dir)) || fs.existsSync(skillsDir);
98
- const installed = fs.existsSync(path.join(skillsDir, 'solution-cli', 'SKILL.md'));
99
- return {
100
- ...a,
101
- detected,
102
- installed,
103
- skillsDir
104
- };
105
- });
158
+ return agents.map(scanAgent);
106
159
  }
107
160
 
108
161
  export function formatAgentList(scannedAgents) {
@@ -147,8 +200,16 @@ export async function promptAgentSelection(scannedAgents, cliArg, autoYes = fals
147
200
  if (cliArg.toLowerCase() === 'all') {
148
201
  return scannedAgents;
149
202
  }
150
- const requested = cliArg.split(',').map(s => s.trim().toLowerCase());
151
- const matched = scannedAgents.filter(a => requested.includes(a.id) || (a.aliases && a.aliases.some(alias => requested.includes(alias))));
203
+ const requested = cliArg.split(',').map(s => s.trim());
204
+ // Validate profile IDs before normalizing other hosts or accepting a partial match.
205
+ for (const id of requested) {
206
+ if (id.toLowerCase().startsWith('hermes:')) {
207
+ const target = scanAgent(hermesTarget(`hermes:${id.slice(7)}`));
208
+ if (!scannedAgents.some(a => a.id === target.id)) scannedAgents.push(target);
209
+ }
210
+ }
211
+ const normalized = requested.map(id => id.toLowerCase());
212
+ const matched = scannedAgents.filter(a => normalized.includes(a.id) || a.aliases?.some(alias => normalized.includes(alias)));
152
213
  if (matched.length === 0) {
153
214
  throw new Error(`No matching agents for: "${cliArg}". Available: ${scannedAgents.map(a => a.id).join(', ')}`);
154
215
  }
package/lib/installer.js CHANGED
@@ -241,6 +241,20 @@ export async function runInstall(options = {}) {
241
241
  recordInstallation(a.id, a.skillsDir, destDir, engineVersion, filesWritten);
242
242
  logger(` ✔ Skill successfully installed to: ${destDir}`);
243
243
  installedAgents.push({ id: a.id, name: a.name, path: destDir });
244
+
245
+ // For doubao agent, ensure secondary scanning directory ~/.agents/skills has symlink if possible
246
+ if (a.id === 'doubao') {
247
+ try {
248
+ const secondarySkills = path.join(os.homedir(), '.agents', 'skills');
249
+ const secondaryDest = path.join(secondarySkills, SKILL_DIR_NAME);
250
+ if (path.resolve(a.skillsDir) !== path.resolve(secondarySkills)) {
251
+ if (fs.existsSync(secondarySkills) && !fs.existsSync(secondaryDest)) {
252
+ fs.symlinkSync(destDir, secondaryDest, 'junction');
253
+ logger(` ✔ Created compatibility link for Doubao: ${secondaryDest} -> ${destDir}`);
254
+ }
255
+ }
256
+ } catch {}
257
+ }
244
258
  }
245
259
  });
246
260
 
package/lib/knowledge.js CHANGED
@@ -711,6 +711,35 @@ export function getHubRecord(id, { type = null, locale = 'zh', section = null, s
711
711
  return row;
712
712
  }
713
713
 
714
+ /**
715
+ * Detects if a search query matches known hardware product families, models, or SKUs.
716
+ *
717
+ * @param {string[]} tokens
718
+ * @param {string} query
719
+ * @param {Object} core
720
+ * @returns {string|null} Helpful redirect note if hardware is detected
721
+ */
722
+ function detectHardwareIntent(tokens, query, core) {
723
+ if (!tokens || tokens.length === 0) return null;
724
+ const families = Object.values(core?.product_families || {});
725
+ const matchedHw = families.find((fam) => {
726
+ const fid = (fam.id || '').toLowerCase();
727
+ const zh = (fam.title?.zh || '').toLowerCase();
728
+ const en = (fam.title?.en || '').toLowerCase();
729
+ if (tokens.some((t) => fid.includes(t) || zh.includes(t) || en.includes(t))) return true;
730
+ return (fam.skus || []).some((s) => {
731
+ const sNum = String(s.sku || '').toLowerCase();
732
+ const sModel = String(s.model || '').toLowerCase();
733
+ const sName = String(s.name || '').toLowerCase();
734
+ return tokens.some((t) => sNum === t || (sModel && sModel.includes(t)) || sName.includes(t));
735
+ });
736
+ });
737
+ if (matchedHw) {
738
+ return `💡 提示: 检测到硬件设备/型号关键词 "${query}"。若需查询该硬件的物理接口、包装清单(是否带电源)或官方落地应用方案,请使用: solution-cli product search "${query}"`;
739
+ }
740
+ return null;
741
+ }
742
+
714
743
  /**
715
744
  * Searches Seeed Solutions Hub scenarios and reference designs.
716
745
  *
@@ -725,6 +754,7 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
725
754
  locale = normalizedLocale;
726
755
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new Error('Limit must be an integer from 1 to 200.');
727
756
  const qLower = (query || '').toLowerCase().trim();
757
+ const tokens = qLower.split(/\s+/).filter(Boolean);
728
758
  const clientConfig = loadClientConfig();
729
759
  const host = process.env.TYPESENSE_HOST || clientConfig?.typesense?.host || 'https://rel-search.seeedstudio.com';
730
760
  const keySolution = process.env.TYPESENSE_SEARCH_KEY_SOLUTION || process.env.TYPESENSE_SEARCH_KEY || clientConfig?.typesense?.search_key_solution;
@@ -896,7 +926,6 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
896
926
 
897
927
  // If one collection returned results but the other was unavailable/failed/empty, supplement from bundled catalog
898
928
  if ((onlineScenarios.length > 0 && onlineRefDesigns.length === 0) || (onlineRefDesigns.length > 0 && onlineScenarios.length === 0)) {
899
- const tokens = qLower.split(/\s+/).filter(Boolean);
900
929
  if (onlineScenarios.length === 0) {
901
930
  for (const s of core.scenarios || []) {
902
931
  if (s.published === false || s.visibility === 'staging' || s.visibility === 'draft') continue;
@@ -939,12 +968,17 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
939
968
  }
940
969
 
941
970
  if (mergedRecords.length > 0) {
971
+ const onlineWarnings = ['Online search results are partial and do not replace the bundled catalog; use hub get for verified section content.'];
972
+ const hwHint = detectHardwareIntent(tokens, query, core);
973
+ if (hwHint) {
974
+ onlineWarnings.push(hwHint);
975
+ }
942
976
  return createCatalogDto({
943
977
  source: (onlineScenarios.some(r => r.source === 'typesense') || onlineRefDesigns.some(r => r.source === 'typesense')) ? 'typesense' : 'bundled_manifest',
944
978
  freshness: 'UNKNOWN',
945
979
  datasetVersion: core.dataset_version,
946
980
  scope: 'online_partial_with_bundled_fallback',
947
- warnings: ['Online search results are partial and do not replace the bundled catalog; use hub get for verified section content.'],
981
+ warnings: onlineWarnings,
948
982
  records: mergedRecords.map(compactSearchRecord),
949
983
  });
950
984
  }
@@ -958,8 +992,6 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
958
992
  const matchedScenarios = [];
959
993
  const matchedRefDesigns = [];
960
994
 
961
- const tokens = qLower.split(/\s+/).filter(Boolean);
962
-
963
995
  for (const s of core.scenarios || []) {
964
996
  if (s.published === false || s.visibility === 'staging' || s.visibility === 'draft') continue;
965
997
  const locKey = locale === 'zh' ? 'zh_hans' : locale;
@@ -1042,17 +1074,25 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
1042
1074
  }
1043
1075
  }
1044
1076
 
1077
+ const warnings = process.env.SENSECRAFT_OFFLINE_ONLY
1078
+ ? ['Offline-only mode: displaying the verified bundled catalog; no online request was attempted.']
1079
+ : ((keySolution || keyRefDesign)
1080
+ ? ['Online Typesense request timed out or returned no hits. Displaying verified local catalog.']
1081
+ : ['No Typesense API key configured or baked. Displaying verified local catalog.']);
1082
+
1083
+ // Detect if query looks like a hardware device, model, or SKU keyword
1084
+ const hwHint = detectHardwareIntent(tokens, query, core);
1085
+ if (hwHint && (combined.length === 0 || (combined[0] && combined[0].score < 2))) {
1086
+ warnings.push(hwHint);
1087
+ }
1088
+
1045
1089
  return createCatalogDto({
1046
1090
  source: 'bundled_manifest',
1047
1091
  freshness: 'UNKNOWN',
1048
1092
  datasetVersion: core.dataset_version,
1049
1093
  scope: 'bundled_catalog_snapshot',
1050
1094
  records: combined.map(compactSearchRecord),
1051
- warnings: process.env.SENSECRAFT_OFFLINE_ONLY
1052
- ? ['Offline-only mode: displaying the verified bundled catalog; no online request was attempted.']
1053
- : ((keySolution || keyRefDesign)
1054
- ? ['Online Typesense request timed out or returned no hits. Displaying verified local catalog.']
1055
- : ['No Typesense API key configured or baked. Displaying verified local catalog.']),
1095
+ warnings,
1056
1096
  });
1057
1097
  }
1058
1098
 
@@ -1083,8 +1123,12 @@ export function queryProductFamily(familyId, { locale = 'zh' } = {}) {
1083
1123
  records: [{
1084
1124
  family_id: family.id,
1085
1125
  title: family.title?.[locale] || family.title?.zh || family.title?.en || family.id,
1126
+ summary: family.summary || null,
1086
1127
  default_sku: family.default_sku,
1087
1128
  regions: family.regions || [],
1129
+ interfaces: family.interfaces || [],
1130
+ protocols: family.protocols || [],
1131
+ supported_scenarios: family.supported_scenarios || [],
1088
1132
  skus_count: (family.skus || []).length,
1089
1133
  skus: (family.skus || []).slice(0, 10).map((s) => ({
1090
1134
  ...s,
@@ -1102,6 +1146,261 @@ export function queryProductFamily(familyId, { locale = 'zh' } = {}) {
1102
1146
  });
1103
1147
  }
1104
1148
 
1149
+ /**
1150
+ * Searches product families and member SKUs by keywords, model name, interface, or SKU number.
1151
+ *
1152
+ * @param {Object} options
1153
+ * @param {string} options.query - Free text query (e.g. "J4012 Super", "RS485", "114110314")
1154
+ * @param {'zh' | 'en' | 'ja'} [options.locale='zh']
1155
+ * @param {number} [options.limit=5]
1156
+ * @returns {Object} Catalog DTO
1157
+ */
1158
+ export function searchProducts({ query = '', locale = 'zh', limit = 5 } = {}) {
1159
+ const core = loadKnowledgeCore();
1160
+ const families = Object.values(core.product_families || {});
1161
+ const qLower = (query || '').toLowerCase().trim();
1162
+ const tokens = qLower.split(/\s+/).filter(Boolean);
1163
+
1164
+ if (tokens.length === 0) {
1165
+ return createCatalogDto({
1166
+ source: 'bundled_manifest',
1167
+ freshness: 'FRESH',
1168
+ records: [],
1169
+ warnings: ['Query cannot be empty.'],
1170
+ });
1171
+ }
1172
+
1173
+ const matches = [];
1174
+
1175
+ for (const fam of families) {
1176
+ let score = 0;
1177
+ const titleZh = (fam.title?.zh || '').toLowerCase();
1178
+ const titleEn = (fam.title?.en || '').toLowerCase();
1179
+ const fid = (fam.id || '').toLowerCase();
1180
+ const famHaystack = [fid, titleZh, titleEn].join(' ');
1181
+
1182
+ // Check SKU matching
1183
+ const matchedSkus = [];
1184
+ for (const sku of fam.skus || []) {
1185
+ const sNum = String(sku.sku || '').toLowerCase();
1186
+ const sModel = String(sku.model || '').toLowerCase();
1187
+ const sName = String(sku.name || '').toLowerCase();
1188
+ // Strip negative clauses like "(Super Mode NOT Supported)" from positive keyword matching
1189
+ const sNameClean = sName
1190
+ .replace(/[(\(][^)\)]*not\s+supported[^)\)]*[)\)]?/gi, '')
1191
+ .replace(/super\s+mode\s+not\s+supported/gi, '')
1192
+ .replace(/\b(?:not\s+supported|unsupported)\b/gi, '');
1193
+ const sAttrs = Object.values(sku.attrs || {}).map((v) => String(v).toLowerCase());
1194
+ const skuIfaces = (sku.specs?.connectivity?.interfaces || sku.interfaces || []).map((i) => String(i).toLowerCase());
1195
+ const skuProtos = (sku.specs?.connectivity?.protocols || sku.protocols || []).map((p) => String(p).toLowerCase());
1196
+ const skuIoPorts = (sku.specs?.io_ports || []).map((p) => String(p.type || '').toLowerCase());
1197
+
1198
+ const skuHaystack = [sNum, sModel, sNameClean, ...sAttrs, ...skuIfaces, ...skuProtos, ...skuIoPorts].join(' ');
1199
+ const isSingleSkuLookup = tokens.length === 1 && (sNum === tokens[0] || sNum === qLower);
1200
+ const skuMatchesAll = tokens.every((t) => skuHaystack.includes(t));
1201
+ const singleTokenHit = tokens.length === 1 && ((sModel && sModel.includes(tokens[0])) || skuHaystack.includes(tokens[0]));
1202
+
1203
+ if (isSingleSkuLookup || skuMatchesAll) {
1204
+ matchedSkus.push(sku);
1205
+ } else if (tokens.length > 1) {
1206
+ const combinedMatchesAll = tokens.every((t) => (famHaystack + ' ' + skuHaystack).includes(t));
1207
+ const nonFamTokens = tokens.filter((t) => !famHaystack.includes(t));
1208
+ if (combinedMatchesAll && (nonFamTokens.length === 0 || nonFamTokens.every((t) => skuHaystack.includes(t)))) {
1209
+ matchedSkus.push(sku);
1210
+ }
1211
+ } else if (singleTokenHit) {
1212
+ matchedSkus.push(sku);
1213
+ }
1214
+ }
1215
+
1216
+ // Require all query tokens to be satisfied either in the family or across member SKUs
1217
+ const famMatchesAll = tokens.every((t) => famHaystack.includes(t));
1218
+ const allTokensSatisfiedInFamily = tokens.every((t) => {
1219
+ return famHaystack.includes(t) || (fam.skus || []).some((s) => {
1220
+ const sNameClean = String(s.name || '')
1221
+ .toLowerCase()
1222
+ .replace(/[(\(][^)\)]*not\s+supported[^)\)]*[)\)]?/gi, '')
1223
+ .replace(/super\s+mode\s+not\s+supported/gi, '');
1224
+ const sIfaces = (s.specs?.connectivity?.interfaces || s.interfaces || []).map((i) => String(i).toLowerCase());
1225
+ const sProtos = (s.specs?.connectivity?.protocols || s.protocols || []).map((p) => String(p).toLowerCase());
1226
+ const sPorts = (s.specs?.io_ports || []).map((p) => String(p.type || '').toLowerCase());
1227
+ return [s.sku, s.model, sNameClean, ...Object.values(s.attrs || {}), ...sIfaces, ...sProtos, ...sPorts].join(' ').toLowerCase().includes(t);
1228
+ });
1229
+ });
1230
+
1231
+ if (!allTokensSatisfiedInFamily) {
1232
+ continue;
1233
+ }
1234
+
1235
+ if (matchedSkus.length > 0 || famMatchesAll) {
1236
+ score = matchedSkus.length * 10 + (famMatchesAll ? 5 : 0);
1237
+ matches.push({
1238
+ family: fam,
1239
+ score,
1240
+ matchedSkus: matchedSkus.length > 0 ? matchedSkus : (fam.skus || []).slice(0, 3),
1241
+ });
1242
+ }
1243
+ }
1244
+
1245
+ matches.sort((a, b) => b.score - a.score);
1246
+
1247
+ const records = matches.slice(0, limit).map(({ family, matchedSkus }) => {
1248
+ return {
1249
+ family_id: family.id,
1250
+ title: family.title?.[locale] || family.title?.zh || family.title?.en || family.id,
1251
+ summary: family.summary || null,
1252
+ default_sku: family.default_sku,
1253
+ interfaces: family.interfaces || [],
1254
+ protocols: family.protocols || [],
1255
+ supported_scenarios: family.supported_scenarios || [],
1256
+ matched_skus: matchedSkus.map((s) => {
1257
+ const specs = s.specs || {};
1258
+ const pwr = specs.power || {};
1259
+ const pkg = specs.package_contents || [];
1260
+ const pkgText = pkg.join(' ').toLowerCase();
1261
+
1262
+ // Require positive evidence of an included operational battery (not just battery_powered capability or RTC battery)
1263
+ const hasIncludedBattery = pkg.some((item) => {
1264
+ if (!/\bbattery\b/i.test(item)) return false;
1265
+ if (/(?:not\s+included|sold\s+separately|cr1220|rtc|coin)/i.test(item)) return false;
1266
+ if (/(?:without|w\/o|no|exclude[ds]?)\s+[^()]*\bbattery/i.test(item)) return false;
1267
+ return true;
1268
+ });
1269
+
1270
+ let adapterIncluded = pwr.adapter_included;
1271
+ if (adapterIncluded === undefined || adapterIncluded === null) {
1272
+ if (s.attrs?.power_adapter === 'not_included' || /w\/?o\s+power/i.test(s.name)) {
1273
+ adapterIncluded = false;
1274
+ } else if (s.attrs?.power_adapter === 'included' || /power\s+adapter/i.test(pkgText)) {
1275
+ adapterIncluded = true;
1276
+ } else {
1277
+ adapterIncluded = hasIncludedBattery ? false : null;
1278
+ }
1279
+ }
1280
+
1281
+ const cordExplicitlyExcluded =
1282
+ s.attrs?.power_adapter === 'included_no_cord' ||
1283
+ s.attrs?.power_cord === 'not_included' ||
1284
+ /without\s+power\s+cord/i.test(pkgText) ||
1285
+ /no\s+power\s+cord/i.test(pkgText) ||
1286
+ /w\/?o\s+power\s+cord/i.test(pkgText) ||
1287
+ /power\s+cord\s+(?:sold\s+separately|not\s+included|optional|excluded)/i.test(pkgText) ||
1288
+ /without\s+power\s+cord/i.test(pwr.adapter_spec || '') ||
1289
+ /without\s+power\s+cord/i.test(s.name || '');
1290
+
1291
+ let powerCordIncluded = null;
1292
+ if (cordExplicitlyExcluded) {
1293
+ powerCordIncluded = false;
1294
+ } else if (s.attrs?.power_cord === 'included') {
1295
+ powerCordIncluded = true;
1296
+ } else if (pkg.length > 0) {
1297
+ const hasExplicitCord = pkg.some((item) => {
1298
+ const itemLower = item.toLowerCase();
1299
+ return /power\s+cord/i.test(itemLower) &&
1300
+ !/(?:without|sold\s+separately|not\s+included|optional|no\s+|w\/o|separately)/i.test(itemLower);
1301
+ });
1302
+ if (hasExplicitCord) {
1303
+ powerCordIncluded = true;
1304
+ } else if (adapterIncluded === true) {
1305
+ const isIntegratedPlug =
1306
+ /universal|wall\s*mount|plug/i.test(pwr.adapter_spec || '') ||
1307
+ /universal\s+power\s+supply/i.test(pkgText) ||
1308
+ /(?:with\s+[^()]*|\b(?:interchangeable|multi-country|global)\s+)plugs?\b/i.test(pkgText);
1309
+ if (isIntegratedPlug) {
1310
+ powerCordIncluded = true;
1311
+ } else {
1312
+ powerCordIncluded = null;
1313
+ }
1314
+ }
1315
+ }
1316
+
1317
+ let outOfTheBoxReady = false;
1318
+ let readinessSummary = '';
1319
+
1320
+ const isDevBoard = /xiao|seeeduino|arduino/i.test(family.id) || /board|development\s+kit/i.test(s.name) || (pwr.input_voltage && /3\.7|5v/i.test(pwr.input_voltage));
1321
+
1322
+ if (hasIncludedBattery) {
1323
+ outOfTheBoxReady = true;
1324
+ readinessSummary = `随附配套电池供电(${pwr.input_connector ? '支持 ' + pwr.input_connector : '支持太阳能/USB充电'}),开箱即可直接上电运行。`;
1325
+ } else if (adapterIncluded === false) {
1326
+ outOfTheBoxReady = false;
1327
+ if (isDevBoard) {
1328
+ readinessSummary = `开发板/套件(仅含主板与配件),需通过 ${pwr.input_connector || 'USB-C'} 供电或自备外接电源/电池。`;
1329
+ } else {
1330
+ readinessSummary = `仅含主机,不随附电源适配器。用户买回去无法直接开机,必须加配 ${pwr.adapter_spec || '合适规格电源适配器'}。`;
1331
+ }
1332
+ } else if (adapterIncluded === true) {
1333
+ if (powerCordIncluded === false) {
1334
+ outOfTheBoxReady = false;
1335
+ readinessSummary = `随附电源适配器(${pwr.adapter_spec || '标配'}),但不随附 AC 电源线,开箱前需自备或加配电源线。`;
1336
+ } else if (powerCordIncluded === true) {
1337
+ outOfTheBoxReady = true;
1338
+ readinessSummary = `随附电源适配器与电源线(或一体式插头),开箱即用。`;
1339
+ } else {
1340
+ outOfTheBoxReady = false;
1341
+ readinessSummary = `随附电源适配器(${pwr.adapter_spec || '标配'}),包装清单未明确电源线随附情况,请核对是否需自备电源线。`;
1342
+ }
1343
+ } else {
1344
+ outOfTheBoxReady = false;
1345
+ readinessSummary = pwr.input_voltage ? `供电规格: ${pwr.input_voltage} ${pwr.input_connector || ''},请核对是否自备电源。` : '电源随附情况未声明。';
1346
+ }
1347
+
1348
+ // Get matching required accessories
1349
+ const requiredAccessories = (family.required_accessories || []).filter((acc) => {
1350
+ if (!acc.applies_when) return true;
1351
+ const attrVal = s.attrs?.[acc.applies_when.attr];
1352
+ const target = acc.applies_when.value;
1353
+ if (Array.isArray(target)) return target.includes(attrVal);
1354
+ return attrVal === target;
1355
+ }).map((acc) => ({
1356
+ id: acc.id,
1357
+ name: acc.name,
1358
+ sku: acc.sku,
1359
+ reason: acc.reason,
1360
+ url: acc.url,
1361
+ }));
1362
+
1363
+ return {
1364
+ sku: s.sku,
1365
+ model: s.model || null,
1366
+ name: s.name,
1367
+ url: appendUtm(s.url, {
1368
+ source: 'sensecraft_agent',
1369
+ medium: 'product_search',
1370
+ campaign: family.id,
1371
+ content: `sku_${s.sku}`,
1372
+ }),
1373
+ package_profile: {
1374
+ power_adapter_included: adapterIncluded,
1375
+ power_cord_included: powerCordIncluded,
1376
+ out_of_the_box_ready: outOfTheBoxReady,
1377
+ readiness_summary: readinessSummary,
1378
+ },
1379
+ required_accessories: requiredAccessories,
1380
+ power: {
1381
+ adapter_included: adapterIncluded,
1382
+ adapter_spec: pwr.adapter_spec || null,
1383
+ input_voltage: pwr.input_voltage || null,
1384
+ input_connector: pwr.input_connector || null,
1385
+ },
1386
+ package_contents: pkg,
1387
+ interfaces: specs.connectivity?.interfaces || family.interfaces || [],
1388
+ io_ports: specs.io_ports || [],
1389
+ expansion_slots: specs.expansion_slots || [],
1390
+ ai_performance: specs.ai_performance || null,
1391
+ };
1392
+ }),
1393
+ };
1394
+ });
1395
+
1396
+ return createCatalogDto({
1397
+ source: 'bundled_manifest',
1398
+ freshness: 'FRESH',
1399
+ records,
1400
+ warnings: records.length === 0 ? [`未找到与 "${query}" 匹配的硬件产品或 SKU。`] : [],
1401
+ });
1402
+ }
1403
+
1105
1404
  /**
1106
1405
  * Queries and verifies accessories for a product family, running physical compatibility checks.
1107
1406
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@seeed-studio/solution-cli",
3
- "version": "0.4.0",
4
- "description": "Installer for the Rust deployment engine and Solution CLI Agent skills (Work Buddy, Claude Code, Codex, CodeBuddy)",
3
+ "version": "0.4.2",
4
+ "description": "Installer for the Rust deployment engine and Solution CLI Agent skills (Doubao Work, WorkBuddy, Claude Code, Codex, CodeBuddy, Hermes)",
5
5
  "type": "module",
6
6
  "main": "./lib/installer.js",
7
7
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "scripts": {
23
23
  "build": "node scripts/bake-client-config.mjs",
24
24
  "prepack": "node scripts/bake-client-config.mjs",
25
- "test": "node --test tests/cli.test.mjs",
25
+ "test": "node --test",
26
26
  "install:self": "node bin/cli.js install",
27
27
  "status": "node bin/cli.js status",
28
28
  "doctor": "node bin/cli.js doctor"
@@ -36,6 +36,7 @@
36
36
  "claude-code",
37
37
  "codex",
38
38
  "codebuddy",
39
+ "hermes",
39
40
  "skills",
40
41
  "edge-ai",
41
42
  "jetson",