@seeed-studio/solution-cli 0.3.0 → 0.3.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
@@ -30,37 +30,78 @@
30
30
  `@seeed-studio/solution-cli` 正是为解决这一系列问题而生,它具有三大独特角色:
31
31
  1. **方案专家的现场诊断仪**:内置各大行业工况诊断知识库,不仅能搜方案,还能直接指导工程师向客户提出决定成本与物理拓扑的“黄金追问”;
32
32
  2. **硬件采购防呆中枢**:内置物理规格匹配引擎,自动核算【必需配件】与【可选配件】,从源头阻断硬件返工;
33
- 3. **AI Agent 的物理世界触手**:一键将 SenseCraft Skill 注入到主流 AI Agent 宿主中,让 Agent 具备从方案咨询、BOM 选型到现场部署、参数热更的全生命周期驱动能力。
33
+ 3. **AI Agent 的物理世界触手**:一键将 solution-cli Skill 注入到主流 AI Agent 宿主中,让 Agent 具备从方案咨询、BOM 选型到现场部署、参数热更的全生命周期驱动能力。
34
34
 
35
35
  ---
36
36
 
37
37
  ## 2. 安装与 Agent 技能挂载
38
38
 
39
39
  ### 2.1 一键免安装即时调用 (推荐)
40
- 无需全局安装,利用 `npx` 即可随时调用:
40
+ 无需全局安装,安装或按需升级 Engine 与 Agent Skill 使用同一条命令:
41
41
  ```bash
42
- # 查看帮助与版本
43
- npx @seeed-studio/solution-cli --help
44
-
45
- # 交互式扫描并为本机所有 Agent 安装技能
46
- npx @seeed-studio/solution-cli install
42
+ npx -y @seeed-studio/solution-cli@latest install --agent workbuddy -y
47
43
  ```
48
44
 
45
+ 上面的示例明确安装到 WorkBuddy。其他宿主将 `workbuddy` 替换为当前实际的
46
+ Agent ID。需要交互选择时使用 `npx -y @seeed-studio/solution-cli@latest install`;
47
+ 检测到一个或多个 Agent 时,直接回车默认选择全部已检测的 Agent,输入对应序号
48
+ 选择指定宿主。非交互模式下,`install -y` 未提供 `--agent` 且检测到 Agent 时也会
49
+ 选择全部已检测的 Agent。未检测到 Agent 时,这些默认仍使用 CLI 的默认目标集合;
50
+ 需要明确控制范围时请提供 `--agent <agent-id>`。
51
+
52
+ 首次安装和重复执行都会检查签名稳定版 Engine:本机已有相同或更新版本
53
+ 时复用,版本较旧时升级。普通安装保留用户修改、未登记的 Skill 文件和受
54
+ 保护的符号链接。需要指定宿主时追加 `--agent claude,antigravity`;需要固
55
+ 定有效本地 Engine 时使用 `--engine-path <path>`。`--force` 可重新下载相同
56
+ 或较旧版本并覆盖已有 Skill 文件(包括用户修改),请只在有意覆盖时使用;
57
+ 受保护的外部符号链接不会追写,也不能降级更新的本机 Engine。
58
+
59
+ 自动复用/升级规则自 solution-cli 0.3.1 起生效;该版本已公开发布。latest 检查或签名校验失败会在
60
+ 修改 Engine、指针或 Skill 前停止;离线时请提供有效的 `--engine-path`。
61
+
62
+ The same command handles a fresh install and a user-requested upgrade. It checks
63
+ the signed stable Engine, reuses an equal/newer local version, preserves modified
64
+ skills, and stops before writes on latest/signature failure. Automatic reuse and
65
+ upgrade rules start with publicly published solution-cli 0.3.1. The example
66
+ targets WorkBuddy with `--agent workbuddy`; replace it with the current Agent ID
67
+ for another host. Use `npx -y @seeed-studio/solution-cli@latest install` for the
68
+ interactive form: when one or more Agents are detected, pressing Enter selects
69
+ all detected Agents, while entering a number selects one host. In non-interactive
70
+ mode, `install -y` without `--agent` also selects all detected Agents when any
71
+ are detected. If none are detected, these defaults still use the CLI's default
72
+ target set; pass `--agent <agent-id>` to control the scope explicitly.
73
+
49
74
  ### 2.2 全局安装
50
75
  ```bash
51
76
  npm install -g @seeed-studio/solution-cli
77
+ solution-cli install --agent workbuddy -y
52
78
  ```
53
79
 
80
+ 全局安装后的挂载规则相同:示例明确选择 WorkBuddy;其他宿主请将
81
+ `workbuddy` 替换为当前实际 Agent ID。交互执行 `solution-cli install` 时,
82
+ 检测到一个或多个 Agent 后直接回车选择全部已检测的 Agent,输入对应序号选择指定
83
+ 宿主;非交互执行 `solution-cli install -y` 且未提供 `--agent` 时,检测到 Agent
84
+ 也会选择全部已检测的 Agent。未检测到 Agent 时,这些默认仍使用 CLI 的默认目标集合;
85
+ 需要明确控制范围时请提供 `--agent <agent-id>`。
86
+
87
+ The global command targets WorkBuddy explicitly. Replace `workbuddy` with the
88
+ current Agent ID for another host. Interactive `solution-cli install` selects all
89
+ detected Agents on Enter when any are detected, or one host by number;
90
+ non-interactive `solution-cli install -y` without `--agent` also selects all
91
+ detected Agents when any are detected. If none are detected, these defaults still
92
+ use the CLI's default target set; pass `--agent <agent-id>` to control the scope
93
+ explicitly.
94
+
54
95
  ### 2.3 跨 Agent 平台技能挂载
55
- 执行 `solution-cli install` 时,工具会自动检测本机存在的 Agent 宿主环境,并将 `sensecraft` Skill 和全套参考手册安全复制到对应目录:
96
+ 执行 `solution-cli install` 时,工具会自动检测本机存在的 Agent 宿主环境,并将 `solution-cli` Skill 和全套参考手册安全复制到对应目录:
56
97
 
57
98
  | Agent 宿主平台 | 技能默认配置路径 | 支持能力 |
58
99
  |---|---|---|
59
- | **Claude Code** | `~/.claude/skills/sensecraft` | 完整方案咨询、工况诊断、BOM 生成、现场部署 |
60
- | **Codex (OpenAI)** | `~/.agents/skills/sensecraft` 或 `~/.codex/skills/sensecraft` | 代码审查、参数重构、二次开发自动化 |
61
- | **Google Antigravity (AGY)** | `~/.gemini/config/skills/sensecraft` | 端到端自主诊断、多 Agent 协同实施 |
62
- | **Work Buddy / Workbody** | `~/.workbuddy/skills/sensecraft` | 桌面端交互、方案展示、一键部署联动 |
63
- | **CodeBuddy** | `~/.codebuddy/skills/sensecraft` | 代码库配对开发与自研模型构建 |
100
+ | **Claude Code** | `~/.claude/skills/solution-cli` | 完整方案咨询、工况诊断、BOM 生成、现场部署 |
101
+ | **Codex (OpenAI)** | `~/.agents/skills/solution-cli` 或 `~/.codex/skills/solution-cli` | 代码审查、参数重构、二次开发自动化 |
102
+ | **Google Antigravity (AGY)** | `~/.gemini/config/skills/solution-cli` | 端到端自主诊断、多 Agent 协同实施 |
103
+ | **Work Buddy / Workbody** | `~/.workbuddy/skills/solution-cli` | 桌面端交互、方案展示、一键部署联动 |
104
+ | **CodeBuddy** | `~/.codebuddy/skills/solution-cli` | 代码库配对开发与自研模型构建 |
64
105
 
65
106
  **非交互式多选安装示例**:
66
107
  ```bash
@@ -100,11 +141,11 @@ solution-cli hub search "跌倒检测" --json
100
141
  把 SenseCAP 土壤与空气传感器接到 Home Assistant 看板上,支持本地私有 ChirpStack 运行,断网可用。
101
142
  预设模式: cloud, tts_local, chirpstack_local
102
143
  现场工况诊断提问指引:
103
- • 监测区域是集中式连栋温室大棚(百米级),还是大面积户外大田/果园(数公里级)?
144
+ • 监测区域是集中式连栋温室大棚(百米级),还是大面积户外大田/果园(数公里级)?
104
145
  (决断考量: 大棚内百米级可采用 RS485 总线;广域大田推荐 LoRaWAN 远距无线,配 SenseCAP M2 网关)
105
- • 现场测点是否有 220V 市电供电,还是需要野外太阳能板/锂电池长续航供电?
146
+ • 现场测点是否有 220V 市电供电,还是需要野外太阳能板/锂电池长续航供电?
106
147
  (决断考量: 市电支持持续高频数据上报;无市电需选配支持休眠唤醒的超低功耗无线传感器)
107
- • 需要监测哪些核心指标?仅空气温湿度,还是包括土壤剖面水肥墒情(水分/EC/pH)?
148
+ • 需要监测哪些核心指标?仅空气温湿度,还是包括土壤剖面水肥墒情(水分/EC/pH)?
108
149
  (决断考量: 决定是配置多合一微气象站,还是选配工业级土壤三参数探头与变送器)
109
150
  ```
110
151
 
@@ -209,7 +250,7 @@ solution-cli product accessories recomputer_j40 --json
209
250
  4. **驱动本地快速联调**:
210
251
  使用 `--solutions-dir` 参数,强制底层引擎加载本地修改版方案,彻底不碰官方缓存:
211
252
  ```bash
212
- sensecraft-engine deploy --solution fall_detection --solutions-dir ./my-solutions/solutions
253
+ sensecraft-engine deploy fall_detection --solutions-dir ./my-solutions/solutions
213
254
  ```
214
255
 
215
256
  ---
package/lib/agent.js CHANGED
@@ -15,8 +15,8 @@ export function getKnownAgents() {
15
15
  // Check where an existing installation might already be
16
16
  const p1 = path.join(home, '.workbuddy', 'skills');
17
17
  const p2 = path.join(home, '.workbody', 'skills');
18
- if (fs.existsSync(path.join(p1, 'sensecraft', 'SKILL.md'))) return p1;
19
- if (fs.existsSync(path.join(p2, 'sensecraft', 'SKILL.md'))) return p2;
18
+ if (fs.existsSync(path.join(p1, 'solution-cli', 'SKILL.md'))) return p1;
19
+ if (fs.existsSync(path.join(p2, 'solution-cli', 'SKILL.md'))) return p2;
20
20
  if (fs.existsSync(path.join(home, '.workbuddy'))) return p1;
21
21
  if (fs.existsSync(path.join(home, '.workbody'))) return p2;
22
22
  return p1;
@@ -36,8 +36,8 @@ export function getKnownAgents() {
36
36
  // Honor existing installation first to avoid path drift (Codex Medium 13)
37
37
  const pAgents = path.join(home, '.agents', 'skills');
38
38
  const pCodex = path.join(home, '.codex', 'skills');
39
- if (fs.existsSync(path.join(pAgents, 'sensecraft', 'SKILL.md'))) return pAgents;
40
- if (fs.existsSync(path.join(pCodex, 'sensecraft', 'SKILL.md'))) return pCodex;
39
+ if (fs.existsSync(path.join(pAgents, 'solution-cli', 'SKILL.md'))) return pAgents;
40
+ if (fs.existsSync(path.join(pCodex, 'solution-cli', 'SKILL.md'))) return pCodex;
41
41
  // Default to .agents/skills if .agents exists, otherwise .codex/skills if .codex exists
42
42
  if (fs.existsSync(path.join(home, '.agents'))) return pAgents;
43
43
  if (fs.existsSync(path.join(home, '.codex'))) return pCodex;
@@ -69,7 +69,7 @@ export function scanLocalAgents() {
69
69
  return agents.map(a => {
70
70
  const skillsDir = a.getSkillsDir();
71
71
  const detected = a.homeDirs.some(dir => fs.existsSync(dir)) || fs.existsSync(skillsDir);
72
- const installed = fs.existsSync(path.join(skillsDir, 'sensecraft', 'SKILL.md'));
72
+ const installed = fs.existsSync(path.join(skillsDir, 'solution-cli', 'SKILL.md'));
73
73
  return {
74
74
  ...a,
75
75
  detected,
package/lib/detector.js CHANGED
@@ -226,7 +226,7 @@ export function detectDesktopApp() {
226
226
  appPath: appBase,
227
227
  binPath: path.join(appBase, 'Contents', 'MacOS', 'sensecraft-engine'),
228
228
  solutionsPath: path.join(appBase, 'Contents', 'Resources', '_up_', 'sensecraft-solutions', 'solutions'),
229
- skillsPath: path.join(appBase, 'Contents', 'Resources', '_up_', 'sensecraft-solutions', 'skills', 'sensecraft')
229
+ skillsPath: path.join(appBase, 'Contents', 'Resources', '_up_', 'sensecraft-solutions', 'skills', 'solution-cli')
230
230
  });
231
231
  const cacheBin = path.join(
232
232
  os.homedir(),
@@ -258,7 +258,7 @@ export function detectDesktopApp() {
258
258
  appPath,
259
259
  binPath: path.join(appPath, 'resources', 'bin', 'sensecraft-engine.exe'),
260
260
  solutionsPath: path.join(appPath, 'resources', 'sensecraft-solutions', 'solutions'),
261
- skillsPath: path.join(appPath, 'resources', 'sensecraft-solutions', 'skills', 'sensecraft')
261
+ skillsPath: path.join(appPath, 'resources', 'sensecraft-solutions', 'skills', 'solution-cli')
262
262
  });
263
263
  }
264
264
  }
@@ -271,7 +271,7 @@ export function detectDesktopApp() {
271
271
  appPath,
272
272
  binPath: path.join(appPath, 'sensecraft-engine'),
273
273
  solutionsPath: path.join(appPath, 'sensecraft-solutions', 'solutions'),
274
- skillsPath: path.join(appPath, 'skills', 'sensecraft')
274
+ skillsPath: path.join(appPath, 'skills', 'solution-cli')
275
275
  });
276
276
  }
277
277
  }
package/lib/downloader.js CHANGED
@@ -4,13 +4,41 @@ import fs from 'node:fs';
4
4
  import crypto from 'node:crypto';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  import { getPlatformInfo, getEngineBinDir, getEngineBinaryPath, getScHome } from './platform.js';
7
- import { verifyEngineBinary } from './detector.js';
7
+ import { verifyEngineBinary, parseSemver } from './detector.js';
8
8
 
9
9
  const LATEST_MANIFEST_URL = 'https://sensecraft-statics.seeed.cc/solution-app/engine/latest.json';
10
+ const VERSIONED_MANIFEST_BASE_URL = 'https://sensecraft-statics.seeed.cc/solution-app/engine';
10
11
  const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024; // 50MB limit
11
12
  const NETWORK_TIMEOUT_MS = 60000; // 60s timeout
12
13
 
13
14
  export const PINNED_MINISIGN_PUBKEY = 'RWTg282IuJHsoMOFZC0DP1q0LrS384gJsCLrXyX3CIzIKLPUHm3GGmBv';
15
+ const verifiedReleases = new WeakSet();
16
+
17
+ function deepFreeze(value) {
18
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
19
+ for (const child of Object.values(value)) deepFreeze(child);
20
+ return Object.freeze(value);
21
+ }
22
+
23
+ export function validateReleaseManifest(manifest, platform) {
24
+ if (!manifest || typeof manifest !== 'object') throw new Error('Engine release manifest is not an object');
25
+ if (manifest.channel !== 'stable') throw new Error(`Engine release manifest channel must be stable (got ${manifest.channel || 'missing'})`);
26
+ const version = manifest.version;
27
+ const parsedVersion = parseSemver(version);
28
+ if (!parsedVersion || parsedVersion.raw !== version || parsedVersion.prerelease) {
29
+ throw new Error(`Engine release manifest contains an invalid release version: ${version || 'missing'}`);
30
+ }
31
+ const asset = manifest.assets?.[platform.target];
32
+ let assetUrl;
33
+ try { assetUrl = new URL(asset?.url); } catch {}
34
+ if (!asset || typeof asset !== 'object' || typeof asset.url !== 'string' || assetUrl?.protocol !== 'https:' || !/^[a-f0-9]{64}$/i.test(asset.sha256 || '')) {
35
+ throw new Error(`No valid compatible release asset found for target "${platform.target}" in version ${version}`);
36
+ }
37
+ if (asset.size !== undefined && (!Number.isInteger(asset.size) || asset.size <= 0 || asset.size > MAX_ARCHIVE_BYTES)) {
38
+ throw new Error(`Declared asset size (${asset.size} bytes) is invalid or exceeds safety maximum (${MAX_ARCHIVE_BYTES} bytes)`);
39
+ }
40
+ return manifest;
41
+ }
14
42
 
15
43
  export function verifyManifestSignature(manifestBytes, sigText, pubKeyB64 = PINNED_MINISIGN_PUBKEY) {
16
44
  try {
@@ -30,20 +58,30 @@ export function verifyManifestSignature(manifestBytes, sigText, pubKeyB64 = PINN
30
58
  }
31
59
  }
32
60
 
33
- export async function fetchManifest() {
61
+ export async function fetchManifest({ version } = {}) {
62
+ let manifestUrl = LATEST_MANIFEST_URL;
63
+ if (version !== undefined) {
64
+ const parsedVersion = parseSemver(version);
65
+ if (!parsedVersion || parsedVersion.raw !== version || parsedVersion.prerelease) {
66
+ throw new Error(`Invalid stable Engine release version requested: ${version}`);
67
+ }
68
+ manifestUrl = `${VERSIONED_MANIFEST_BASE_URL}/${version}/manifest.json`;
69
+ }
34
70
  const controller = new AbortController();
35
71
  const timeout = setTimeout(() => controller.abort(), 15000);
72
+ let requestUrl = manifestUrl;
36
73
  try {
37
- const res = await fetch(LATEST_MANIFEST_URL, { signal: controller.signal });
74
+ const res = await fetch(requestUrl, { signal: controller.signal });
38
75
  if (!res.ok) {
39
- throw new Error(`Failed to fetch engine manifest from ${LATEST_MANIFEST_URL}: HTTP ${res.status}`);
76
+ throw new Error(`Failed to fetch engine manifest from ${manifestUrl}: HTTP ${res.status}`);
40
77
  }
41
78
  const manifestBytes = Buffer.from(await res.arrayBuffer());
42
79
 
43
80
  // Fetch and verify detached signature (Codex P1)
44
- const sigRes = await fetch(`${LATEST_MANIFEST_URL}.minisig`, { signal: controller.signal });
81
+ requestUrl = `${manifestUrl}.minisig`;
82
+ const sigRes = await fetch(requestUrl, { signal: controller.signal });
45
83
  if (!sigRes.ok) {
46
- throw new Error(`Failed to fetch manifest signature from ${LATEST_MANIFEST_URL}.minisig: HTTP ${sigRes.status}`);
84
+ throw new Error(`Failed to fetch manifest signature from ${manifestUrl}.minisig: HTTP ${sigRes.status}`);
47
85
  }
48
86
  const sigText = await sigRes.text();
49
87
  const isSigValid = verifyManifestSignature(manifestBytes, sigText);
@@ -51,7 +89,24 @@ export async function fetchManifest() {
51
89
  throw new Error('Engine manifest signature verification failed against trusted Seeed public key');
52
90
  }
53
91
 
54
- return JSON.parse(manifestBytes.toString('utf8'));
92
+ let manifest;
93
+ try { manifest = JSON.parse(manifestBytes.toString('utf8')); } catch (err) {
94
+ throw new Error(`Engine release manifest is not valid JSON: ${err.message}`);
95
+ }
96
+ if (version !== undefined && manifest.version !== version) {
97
+ throw new Error(`Versioned Engine manifest mismatch: requested ${version}, received ${manifest.version || 'missing'}`);
98
+ }
99
+ const release = deepFreeze(manifest);
100
+ verifiedReleases.add(release);
101
+ return release;
102
+ } catch (err) {
103
+ if (err?.name === 'AbortError' || err?.name === 'TypeError') {
104
+ throw new Error(
105
+ `Unable to fetch Engine release metadata/signature from ${requestUrl}. Check CDN network connectivity and retry, or use a valid --engine-path.`,
106
+ { cause: err }
107
+ );
108
+ }
109
+ throw err;
55
110
  } finally {
56
111
  clearTimeout(timeout);
57
112
  }
@@ -65,17 +120,14 @@ export async function downloadEngine(options = {}) {
65
120
  throw new Error(`Cannot download engine: ${platform.note}`);
66
121
  }
67
122
 
68
- onProgress('Fetching latest release manifest...');
69
- const manifest = await fetchManifest();
70
- const asset = manifest.assets ? manifest.assets[platform.target] : null;
71
-
72
- if (!asset || !asset.url || !asset.sha256) {
73
- throw new Error(`No compatible release asset found for target "${platform.target}" in version ${manifest.version}`);
74
- }
75
-
76
- if (asset.size && asset.size > MAX_ARCHIVE_BYTES) {
77
- throw new Error(`Declared asset size (${asset.size} bytes) exceeds safety maximum (${MAX_ARCHIVE_BYTES} bytes)`);
123
+ let manifest = options.release;
124
+ if (manifest === undefined) {
125
+ onProgress('Fetching latest release manifest...');
126
+ manifest = await fetchManifest();
78
127
  }
128
+ if (!verifiedReleases.has(manifest)) throw new Error('Engine release metadata was not verified by the trusted manifest flow');
129
+ validateReleaseManifest(manifest, platform);
130
+ const asset = manifest.assets[platform.target];
79
131
 
80
132
  onProgress(`Downloading sensecraft-engine v${manifest.version}...`);
81
133
  const controller = new AbortController();
@@ -113,6 +165,10 @@ export async function downloadEngine(options = {}) {
113
165
  clearTimeout(timeout);
114
166
  }
115
167
 
168
+ if (asset.size !== undefined && buffer.length !== asset.size) {
169
+ throw new Error(`Downloaded archive size ${buffer.length} bytes does not match signed manifest size ${asset.size} bytes`);
170
+ }
171
+
116
172
  onProgress('Verifying cryptographic SHA256 integrity...');
117
173
  const computedHash = crypto.createHash('sha256').update(buffer).digest('hex').toLowerCase();
118
174
  const expectedHash = asset.sha256.toLowerCase();
@@ -165,12 +221,20 @@ export async function downloadEngine(options = {}) {
165
221
  if (!verifyResult.valid) {
166
222
  throw new Error(`Downloaded engine verification failed: ${verifyResult.reason}`);
167
223
  }
224
+ if (verifyResult.version !== manifest.version) {
225
+ throw new Error(`Downloaded engine version ${verifyResult.version} does not match signed manifest version ${manifest.version}`);
226
+ }
168
227
 
169
228
  const finalBin = getEngineBinaryPath();
170
229
  const backupBin = path.join(binDir, `.backup-${platform.binaryName}-${nonce}`);
230
+ const hasEntry = filePath => Boolean(fs.lstatSync(filePath, { throwIfNoEntry: false }));
171
231
 
172
- if (fs.existsSync(finalBin)) {
173
- try { fs.renameSync(finalBin, backupBin); } catch {}
232
+ if (hasEntry(finalBin)) {
233
+ try {
234
+ fs.renameSync(finalBin, backupBin);
235
+ } catch (err) {
236
+ throw new Error(`Unable to back up existing Engine before swap: ${err.message}`);
237
+ }
174
238
  }
175
239
 
176
240
  try {
@@ -179,11 +243,11 @@ export async function downloadEngine(options = {}) {
179
243
  } else {
180
244
  fs.renameSync(stagedBin, finalBin);
181
245
  }
182
- if (fs.existsSync(backupBin)) {
246
+ if (hasEntry(backupBin)) {
183
247
  try { fs.unlinkSync(backupBin); } catch {}
184
248
  }
185
249
  } catch (swapErr) {
186
- if (fs.existsSync(backupBin)) {
250
+ if (hasEntry(backupBin)) {
187
251
  try { fs.renameSync(backupBin, finalBin); } catch {}
188
252
  }
189
253
  throw swapErr;
package/lib/installer.js CHANGED
@@ -8,9 +8,9 @@ import {
8
8
  setConfiguredEnginePath,
9
9
  getConfiguredEnginePath
10
10
  } from './platform.js';
11
- import { detectExistingEngine, detectDesktopApp, verifyEngineBinary } from './detector.js';
12
- import { downloadEngine, pullSolutionContent } from './downloader.js';
13
- import { scanLocalAgents, promptAgentSelection } from './agent.js';
11
+ import { detectExistingEngine, detectDesktopApp, verifyEngineBinary, compareSemver } from './detector.js';
12
+ import { downloadEngine, fetchManifest, validateReleaseManifest, pullSolutionContent } from './downloader.js';
13
+ import { getKnownAgents, scanLocalAgents, promptAgentSelection } from './agent.js';
14
14
  import {
15
15
  recordInstallation,
16
16
  removeInstallation,
@@ -22,7 +22,31 @@ import {
22
22
 
23
23
  const __filename = fileURLToPath(import.meta.url);
24
24
  const __dirname = path.dirname(__filename);
25
- const BUNDLED_SKILLS_SRC = path.resolve(__dirname, '..', 'skills', 'sensecraft');
25
+ const SKILL_DIR_NAME = 'solution-cli';
26
+ const LEGACY_SKILL_DIR_NAME = 'sensecraft';
27
+ const BUNDLED_SKILLS_SRC = path.resolve(__dirname, '..', 'skills', SKILL_DIR_NAME);
28
+
29
+ function assertCanonicalSkillState(ledger, agents) {
30
+ const allAgents = agents;
31
+ const errors = [];
32
+
33
+ for (const agent of allAgents) {
34
+ const skillsDir = agent.skillsDir || agent.getSkillsDir();
35
+ const expectedTarget = path.resolve(path.join(skillsDir, SKILL_DIR_NAME));
36
+ const legacySkill = path.join(skillsDir, LEGACY_SKILL_DIR_NAME, 'SKILL.md');
37
+ if (fs.lstatSync(legacySkill, { throwIfNoEntry: false })) {
38
+ errors.push(`${agent.id} still has legacy skill at ${legacySkill}; run uninstall --agent ${agent.id} before installing solution-cli. If the file is untracked or remains after uninstall because it was modified, back it up and retire the legacy skill directory before retrying; user files are not removed automatically.`);
39
+ }
40
+ const record = ledger.installations?.[agent.id];
41
+ if (record && path.resolve(record.target_dir) !== expectedTarget) {
42
+ errors.push(`${agent.id} is managed at legacy target ${record.target_dir}; run uninstall --agent ${agent.id} before installing solution-cli`);
43
+ }
44
+ }
45
+
46
+ if (errors.length > 0) {
47
+ throw new Error(`Legacy SenseCraft skill state blocks canonical solution-cli installation:\n- ${errors.join('\n- ')}`);
48
+ }
49
+ }
26
50
 
27
51
  export async function runInstall(options = {}) {
28
52
  const {
@@ -36,7 +60,7 @@ export async function runInstall(options = {}) {
36
60
  } = options;
37
61
 
38
62
  logger('\x1b[1m\x1b[34m====================================================\x1b[0m');
39
- logger('\x1b[1m\x1b[34m SenseCraft Engine & AI Agent Skill Installer \x1b[0m');
63
+ logger('\x1b[1m\x1b[34m Solution CLI Installer \x1b[0m');
40
64
  logger('\x1b[1m\x1b[34m====================================================\x1b[0m\n');
41
65
 
42
66
  const platform = getPlatformInfo();
@@ -53,6 +77,9 @@ export async function runInstall(options = {}) {
53
77
  return { success: true, engineBin: null, installedAgents: [] };
54
78
  }
55
79
 
80
+ // Validate ownership and legacy targets before engine selection or pointer/download writes.
81
+ assertCanonicalSkillState(loadLedger(), selectedAgents);
82
+
56
83
  // --- Step 1: Detect & Verify Engine Candidate ---
57
84
  logger('🔍 Phase 1: Inspecting host environment for existing Engine/App...');
58
85
  let engineBin = null;
@@ -69,52 +96,40 @@ export async function runInstall(options = {}) {
69
96
  engineBin = resolvedPath;
70
97
  engineVersion = check.version;
71
98
  reusedSource = 'custom';
72
- } else if (!force) {
73
- // 1.1 Check default / PATH engine
99
+ } else {
100
+ // Discover every compatible local source before comparing with the signed stable release.
74
101
  const existing = detectExistingEngine();
75
- if (existing) {
76
- logger(`✔ Found existing compatible Engine v${existing.version} (${existing.path})`);
77
- engineBin = path.resolve(existing.path);
78
- engineVersion = existing.version;
79
- reusedSource = existing.source;
80
- } else {
81
- // 1.2 Check Desktop App
82
- const app = detectDesktopApp();
83
- if (app.detected && app.compatible) {
84
- logger(`✔ Found compatible SenseCraft Desktop App at: ${app.appPath || app.binPath}`);
85
- logger(` Engine binary inside App: ${app.binPath} (v${app.version})`);
86
-
87
- const targetBin = path.resolve(getEngineBinaryPath());
88
- if (!dryRun) {
89
- fs.mkdirSync(path.dirname(targetBin), { recursive: true });
90
- if (platform.isWindows) {
91
- fs.copyFileSync(app.binPath, targetBin);
92
- } else {
93
- if (fs.existsSync(targetBin)) fs.unlinkSync(targetBin);
94
- fs.symlinkSync(app.binPath, targetBin);
95
- }
96
- engineBin = targetBin;
97
- } else {
98
- engineBin = app.binPath;
99
- }
100
- engineVersion = app.version;
101
- reusedSource = 'desktop_app';
102
- } else if (app.detected && !app.compatible) {
103
- logger(`ℹ Detected existing App but its engine is outdated/incompatible: ${app.reason}. Falling back to fresh engine download.`);
104
- }
102
+ const app = detectDesktopApp();
103
+ const candidates = [];
104
+ if (existing) candidates.push({ ...existing, kind: 'local' });
105
+ if (app.detected && app.compatible) candidates.push({ ...app, path: app.binPath, kind: 'desktop_app' });
106
+ if (app.detected && !app.compatible) {
107
+ logger(`ℹ Detected existing App but its engine is outdated/incompatible: ${app.reason}`);
105
108
  }
106
- }
107
-
108
- // 1.3 If no compatible local candidate, download standalone binary
109
- if (!engineBin) {
110
- if (dryRun) {
111
- logger('ℹ [dry-run] Would download standalone engine from CDN...');
109
+ candidates.sort((a, b) => compareSemver(b.version, a.version));
110
+ const selected = candidates[0] || null;
111
+
112
+ logger('🔐 Checking the signed stable Engine release manifest...');
113
+ const release = await fetchManifest();
114
+ validateReleaseManifest(release, platform);
115
+ const releaseVersion = release.version;
116
+ const selectedIsCurrent = selected && (compareSemver(selected.version, releaseVersion) > 0 ||
117
+ (compareSemver(selected.version, releaseVersion) === 0 && !force));
118
+
119
+ if (selectedIsCurrent) {
120
+ engineBin = path.resolve(selected.path);
121
+ engineVersion = selected.version;
122
+ reusedSource = selected.kind === 'desktop_app' ? 'desktop_app' : selected.source;
123
+ logger(`✔ Reusing highest compatible Engine v${engineVersion} (${engineBin}); signed stable is v${releaseVersion}`);
124
+ } else if (dryRun) {
112
125
  engineBin = path.resolve(getEngineBinaryPath());
113
- engineVersion = '0.0.3';
126
+ engineVersion = releaseVersion;
114
127
  reusedSource = 'download';
128
+ logger(`ℹ [dry-run] Would ${selected ? `upgrade Engine v${selected.version} to` : 'download'} signed stable Engine v${releaseVersion} to ${engineBin}`);
115
129
  } else {
116
- logger('⬇ Downloading latest standalone engine binary from CDN...');
130
+ logger(`⬇ ${selected ? `Upgrading Engine v${selected.version} to` : 'Downloading'} signed stable Engine v${releaseVersion} from CDN...`);
117
131
  const dl = await downloadEngine({
132
+ release,
118
133
  onProgress: msg => logger(` ${msg}`)
119
134
  });
120
135
  engineBin = path.resolve(dl.path);
@@ -147,9 +162,10 @@ export async function runInstall(options = {}) {
147
162
 
148
163
  runWithLock(() => {
149
164
  const ledger = dryRun ? { installations: {} } : loadLedger();
165
+ assertCanonicalSkillState(ledger, selectedAgents);
150
166
 
151
167
  for (const a of selectedAgents) {
152
- const destDir = path.resolve(path.join(a.skillsDir, 'sensecraft'));
168
+ const destDir = path.resolve(path.join(a.skillsDir, SKILL_DIR_NAME));
153
169
  logger(`\nConfiguring ${a.name} at: ${destDir}...`);
154
170
 
155
171
  if (dryRun) {
@@ -168,6 +184,21 @@ export async function runInstall(options = {}) {
168
184
 
169
185
  Object.assign(filesWritten, copyResult.filesWritten);
170
186
 
187
+ if (copyResult.preservedFiles.includes('SKILL.md')) {
188
+ const preservedSkillPath = path.join(destDir, 'SKILL.md');
189
+ const preservedStat = fs.lstatSync(preservedSkillPath, { throwIfNoEntry: false });
190
+ if (preservedStat && !preservedStat.isSymbolicLink()) {
191
+ const preservedSkill = fs.readFileSync(preservedSkillPath, 'utf8');
192
+ const oldPathMatch = preservedSkill.match(/\*\*Solution CLI Runtime\*\*:[\s\S]*?full absolute path: `"([^"]+)"/);
193
+ const normalizePath = value => path.normalize(value.replaceAll('\\', '/'));
194
+ if (oldPathMatch && normalizePath(oldPathMatch[1]) !== normalizePath(engineBin)) {
195
+ logger(` ⚠️ Preserved user-modified SKILL.md still contains its previous Engine path; user text was not rewritten.`);
196
+ } else {
197
+ logger(' ⚠️ Preserved user-modified SKILL.md; user text was not rewritten.');
198
+ }
199
+ }
200
+ }
201
+
171
202
  // Customize SKILL.md only if actually copied by this install and not an external symlink (Codex P1)
172
203
  const skillMdPath = path.join(destDir, 'SKILL.md');
173
204
  if (fs.existsSync(skillMdPath) && copyResult.updatedFiles.includes('SKILL.md')) {
@@ -175,9 +206,9 @@ export async function runInstall(options = {}) {
175
206
  if (skillStat && !skillStat.isSymbolicLink()) {
176
207
  let content = fs.readFileSync(skillMdPath, 'utf8');
177
208
  const escapedBinPath = engineBin.replace(/\\/g, '/');
178
- const instructionBlock = `\n> **SenseCraft Runtime**: All steps run via \`${escapedBinPath}\`. If shell $PATH does not contain \`sensecraft-engine\`, always invoke using the full absolute path: \`"${escapedBinPath}" <subcommand>\`.\n\n`;
209
+ const instructionBlock = `\n> **Solution CLI Runtime**: Engine operations such as \`device\`, \`deploy\`, and \`logs\` must use the full absolute path: \`"${escapedBinPath}" <subcommand>\`. Use \`solution-cli\` for catalog and product queries; if it is not on $PATH, invoke \`npx -y --package=@seeed-studio/solution-cli@latest solution-cli <args>\`.\n\n`;
179
210
 
180
- if (!content.includes('SenseCraft Runtime:')) {
211
+ if (!content.includes('**Solution CLI Runtime**:')) {
181
212
  content = content.replace(/(---[\s\S]*?---\n)/, `$1${instructionBlock}`);
182
213
  fs.writeFileSync(skillMdPath, content, 'utf8');
183
214
  }
@@ -186,7 +217,7 @@ export async function runInstall(options = {}) {
186
217
  }
187
218
 
188
219
  // Write installation marker safely without following external symlinks (Codex P2)
189
- const markerPath = path.join(destDir, '.installed-by-sensecraft');
220
+ const markerPath = path.join(destDir, '.installed-by-solution-cli');
190
221
  if (isSafePathInside(markerPath, destDir)) {
191
222
  const markerStat = fs.lstatSync(markerPath, { throwIfNoEntry: false });
192
223
  if (markerStat && markerStat.isSymbolicLink()) {
@@ -207,7 +238,7 @@ export async function runInstall(options = {}) {
207
238
  });
208
239
 
209
240
  logger('\n\x1b[1m\x1b[32m====================================================\x1b[0m');
210
- logger('\x1b[1m\x1b[32m 🎉 SenseCraft Setup Completed Successfully! \x1b[0m');
241
+ logger('\x1b[1m\x1b[32m 🎉 Solution CLI Setup Completed Successfully! \x1b[0m');
211
242
  logger('\x1b[1m\x1b[32m====================================================\x1b[0m');
212
243
  logger(`\n🔧 Engine Binary: ${engineBin}`);
213
244
  logger(`🤖 Configured Agents:`);
@@ -259,7 +290,7 @@ export function runStatus(options = {}) {
259
290
  return statusObj;
260
291
  }
261
292
 
262
- logger('\n📊 SenseCraft System Status:\n');
293
+ logger('\n📊 Solution CLI System Status:\n');
263
294
  logger(` Engine Binary: ${binPath}`);
264
295
  logger(` Engine Version: ${engineCheck.version || 'Not installed'} (${engineCheck.valid ? 'Valid' : 'Invalid'})`);
265
296
  logger(` Deployers Available: ${engineCheck.deployers?.length || 0}`);
@@ -283,7 +314,7 @@ export function runUninstall(options = {}) {
283
314
  const installedIds = Object.keys(ledger.installations || {});
284
315
 
285
316
  if (installedIds.length === 0) {
286
- logger('No managed SenseCraft skills are currently recorded in the ledger.');
317
+ logger('No managed Solution CLI skills are currently recorded in the ledger.');
287
318
  return;
288
319
  }
289
320
 
@@ -315,6 +346,7 @@ export function runUninstall(options = {}) {
315
346
  function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logger) {
316
347
  const filesWritten = {};
317
348
  const updatedFiles = [];
349
+ const preservedFiles = [];
318
350
  const entries = fs.readdirSync(srcDir, { withFileTypes: true });
319
351
 
320
352
  for (const entry of entries) {
@@ -333,6 +365,7 @@ function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logge
333
365
  const sub = copyFolderSafely(srcPath, destPath, baseDestDir, priorFiles, force, logger);
334
366
  Object.assign(filesWritten, sub.filesWritten);
335
367
  updatedFiles.push(...sub.updatedFiles);
368
+ preservedFiles.push(...sub.preservedFiles);
336
369
  } else {
337
370
  let shouldCopy = true;
338
371
 
@@ -348,12 +381,14 @@ function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logge
348
381
  // CRUCIAL (Codex High 1): Keep the ORIGINAL baseline hash in the ledger!
349
382
  // Do NOT record destHash, otherwise future uninstall will consider it unmodified!
350
383
  filesWritten[relPath] = recordedBaselineHash;
384
+ preservedFiles.push(relPath);
351
385
  }
352
386
  } else {
353
387
  // File exists on disk but was never in ledger (unmanaged foreign file)
354
388
  if (!force) {
355
389
  logger(` ⚠️ Refused overwriting unmanaged pre-existing file: ${relPath} (use --force)`);
356
390
  shouldCopy = false;
391
+ preservedFiles.push(relPath);
357
392
  }
358
393
  }
359
394
  }
@@ -371,5 +406,5 @@ function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logge
371
406
  }
372
407
  }
373
408
 
374
- return { filesWritten, updatedFiles };
409
+ return { filesWritten, updatedFiles, preservedFiles };
375
410
  }