@seeed-studio/solution-cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -2
- package/bin/cli.js +136 -0
- package/bin/runner.js +39 -0
- package/lib/agent.js +160 -0
- package/lib/detector.js +321 -0
- package/lib/doctor.js +179 -0
- package/lib/downloader.js +219 -0
- package/lib/installer.js +375 -0
- package/lib/ledger.js +302 -0
- package/lib/platform.js +113 -0
- package/package.json +51 -4
- package/skills/sensecraft/SKILL.md +176 -0
- package/skills/sensecraft/references/app-only.md +273 -0
- package/skills/sensecraft/references/deploy.md +300 -0
- package/skills/sensecraft/references/diagnose.md +133 -0
- package/skills/sensecraft/references/error-catalog.md +33 -0
- package/skills/sensecraft/references/offline.md +78 -0
- package/skills/sensecraft/references/verify.md +136 -0
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { getScHome } from './platform.js';
|
|
5
|
+
|
|
6
|
+
export function getLedgerPath() {
|
|
7
|
+
return path.join(getScHome(), 'skills-ledger.json');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getLockPath() {
|
|
11
|
+
return path.join(getScHome(), '.skills.lock');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function withLedgerLock(fn) {
|
|
15
|
+
const lockFile = getLockPath();
|
|
16
|
+
const dir = path.dirname(lockFile);
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
|
|
19
|
+
const maxAttempts = 60;
|
|
20
|
+
let attempts = 0;
|
|
21
|
+
let fd = null;
|
|
22
|
+
|
|
23
|
+
while (attempts++ < maxAttempts) {
|
|
24
|
+
try {
|
|
25
|
+
fd = fs.openSync(lockFile, 'wx');
|
|
26
|
+
// Record PID and timestamp into lock file
|
|
27
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, time: Date.now() }), 'utf8');
|
|
28
|
+
break;
|
|
29
|
+
} catch {
|
|
30
|
+
// Check if lock is stale or holder process is dead
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(lockFile, 'utf8');
|
|
33
|
+
const lockInfo = JSON.parse(raw);
|
|
34
|
+
let isDead = false;
|
|
35
|
+
|
|
36
|
+
if (lockInfo && lockInfo.pid) {
|
|
37
|
+
try {
|
|
38
|
+
// Check if process exists: kill(0) throws ESRCH if process is dead
|
|
39
|
+
process.kill(lockInfo.pid, 0);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err.code === 'ESRCH') {
|
|
42
|
+
isDead = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Break stale lock only if process is dead or older than 60s
|
|
48
|
+
if (isDead || (Date.now() - lockInfo.time > 60000)) {
|
|
49
|
+
fs.unlinkSync(lockFile);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
|
|
54
|
+
// Sleep 100ms synchronously
|
|
55
|
+
const end = Date.now() + 100;
|
|
56
|
+
while (Date.now() < end) {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (fd === null) {
|
|
61
|
+
throw new Error('Could not acquire installation lock; another SenseCraft operation may be running.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
return fn();
|
|
66
|
+
} finally {
|
|
67
|
+
try { fs.closeSync(fd); } catch {}
|
|
68
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function loadLedger() {
|
|
73
|
+
const ledgerPath = getLedgerPath();
|
|
74
|
+
if (!fs.existsSync(ledgerPath)) {
|
|
75
|
+
return {
|
|
76
|
+
schema_version: 1,
|
|
77
|
+
installations: {}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const raw = fs.readFileSync(ledgerPath, 'utf8');
|
|
82
|
+
const parsed = JSON.parse(raw);
|
|
83
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.installations) {
|
|
84
|
+
throw new Error('Malformed skills ledger JSON schema');
|
|
85
|
+
}
|
|
86
|
+
return parsed;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new Error(`Corrupted skills ledger at ${ledgerPath}: ${err.message}. Please inspect or backup.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function saveLedger(ledger) {
|
|
93
|
+
const ledgerPath = getLedgerPath();
|
|
94
|
+
const dir = path.dirname(ledgerPath);
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
const tempFile = path.join(dir, `.skills-ledger.tmp-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`);
|
|
98
|
+
fs.writeFileSync(tempFile, JSON.stringify(ledger, null, 2), 'utf8');
|
|
99
|
+
fs.renameSync(tempFile, ledgerPath);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function computeFileHash(filePath) {
|
|
103
|
+
if (!fs.existsSync(filePath)) return null;
|
|
104
|
+
const content = fs.readFileSync(filePath);
|
|
105
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Ensure target file stays strictly inside targetDir, refusing symlink escapes (Codex High 2 & P2)
|
|
109
|
+
export function isSafePathInside(childPath, parentDir) {
|
|
110
|
+
try {
|
|
111
|
+
const parentReal = fs.realpathSync(parentDir);
|
|
112
|
+
|
|
113
|
+
// Codex P2: Detect symlinks even if target does not exist yet (dangling symlinks)
|
|
114
|
+
const lstat = fs.lstatSync(childPath, { throwIfNoEntry: false });
|
|
115
|
+
if (lstat && lstat.isSymbolicLink()) {
|
|
116
|
+
const linkTarget = fs.readlinkSync(childPath);
|
|
117
|
+
const resolved = path.resolve(path.dirname(childPath), linkTarget);
|
|
118
|
+
if (!fs.existsSync(resolved)) {
|
|
119
|
+
return false; // Reject dangling symlink destination
|
|
120
|
+
}
|
|
121
|
+
const targetReal = fs.realpathSync(resolved);
|
|
122
|
+
const relTarget = path.relative(parentReal, targetReal);
|
|
123
|
+
return !relTarget.startsWith('..') && !path.isAbsolute(relTarget);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// If child exists, verify its realpath
|
|
127
|
+
if (fs.existsSync(childPath)) {
|
|
128
|
+
const childReal = fs.realpathSync(childPath);
|
|
129
|
+
const rel = path.relative(parentReal, childReal);
|
|
130
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
131
|
+
}
|
|
132
|
+
// If child doesn't exist yet, verify its nearest existing ancestor
|
|
133
|
+
let cur = path.resolve(childPath);
|
|
134
|
+
while (cur !== path.dirname(cur)) {
|
|
135
|
+
if (fs.existsSync(cur)) {
|
|
136
|
+
const curReal = fs.realpathSync(cur);
|
|
137
|
+
const rel = path.relative(parentReal, curReal);
|
|
138
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
139
|
+
}
|
|
140
|
+
cur = path.dirname(cur);
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function checkFileModifications(targetDir, filesMap) {
|
|
149
|
+
const modified = [];
|
|
150
|
+
const missing = [];
|
|
151
|
+
const intact = [];
|
|
152
|
+
|
|
153
|
+
for (const [relPath, recordedHash] of Object.entries(filesMap || {})) {
|
|
154
|
+
if (relPath.includes('..') || path.isAbsolute(relPath)) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const fullPath = path.join(targetDir, relPath);
|
|
158
|
+
if (!isSafePathInside(fullPath, targetDir)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!fs.existsSync(fullPath)) {
|
|
163
|
+
missing.push(relPath);
|
|
164
|
+
} else {
|
|
165
|
+
const currentHash = computeFileHash(fullPath);
|
|
166
|
+
if (currentHash !== recordedHash) {
|
|
167
|
+
modified.push(relPath);
|
|
168
|
+
} else {
|
|
169
|
+
intact.push(relPath);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { modified, missing, intact };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function recordInstallation(agentId, skillsDir, targetDir, skillVersion, filesMap) {
|
|
178
|
+
const ledger = loadLedger();
|
|
179
|
+
ledger.installations = ledger.installations || {};
|
|
180
|
+
|
|
181
|
+
ledger.installations[agentId] = {
|
|
182
|
+
agent_id: agentId,
|
|
183
|
+
skills_dir: path.resolve(skillsDir),
|
|
184
|
+
target_dir: path.resolve(targetDir),
|
|
185
|
+
skill_version: skillVersion,
|
|
186
|
+
installed_at: new Date().toISOString(),
|
|
187
|
+
files: filesMap
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
ledger.last_updated = new Date().toISOString();
|
|
191
|
+
saveLedger(ledger);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function removeInstallation(agentId, options = {}) {
|
|
195
|
+
const { force = false, dryRun = false } = options;
|
|
196
|
+
const runWithLock = dryRun ? fn => fn() : withLedgerLock;
|
|
197
|
+
|
|
198
|
+
return runWithLock(() => {
|
|
199
|
+
const ledger = loadLedger();
|
|
200
|
+
|
|
201
|
+
if (!ledger.installations || !ledger.installations[agentId]) {
|
|
202
|
+
return { success: false, reason: `No tracked installation found for agent "${agentId}".` };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const record = ledger.installations[agentId];
|
|
206
|
+
const targetDir = path.resolve(record.target_dir);
|
|
207
|
+
|
|
208
|
+
if (!fs.existsSync(targetDir)) {
|
|
209
|
+
if (!dryRun) {
|
|
210
|
+
delete ledger.installations[agentId];
|
|
211
|
+
saveLedger(ledger);
|
|
212
|
+
}
|
|
213
|
+
return { success: true, message: 'Target directory already absent. Cleaned ledger record.' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const files = record.files || {};
|
|
217
|
+
let removedCount = 0;
|
|
218
|
+
let preservedCount = 0;
|
|
219
|
+
const actions = [];
|
|
220
|
+
|
|
221
|
+
for (const [relPath, recordedHash] of Object.entries(files)) {
|
|
222
|
+
if (relPath.includes('..') || path.isAbsolute(relPath)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const fullPath = path.join(targetDir, relPath);
|
|
226
|
+
|
|
227
|
+
// Verify safe path inside targetDir (Codex High 2: Prevent symlink escape)
|
|
228
|
+
if (!isSafePathInside(fullPath, targetDir)) {
|
|
229
|
+
actions.push({ file: fullPath, action: 'skip_symlink_escape' });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (fs.existsSync(fullPath)) {
|
|
234
|
+
const currentHash = computeFileHash(fullPath);
|
|
235
|
+
if (currentHash === recordedHash || force) {
|
|
236
|
+
actions.push({ file: fullPath, action: 'delete' });
|
|
237
|
+
if (!dryRun) {
|
|
238
|
+
fs.unlinkSync(fullPath);
|
|
239
|
+
}
|
|
240
|
+
removedCount++;
|
|
241
|
+
} else {
|
|
242
|
+
actions.push({ file: fullPath, action: 'preserve_modified' });
|
|
243
|
+
preservedCount++;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Remove marker file
|
|
249
|
+
const marker = path.join(targetDir, '.installed-by-sensecraft');
|
|
250
|
+
if (fs.existsSync(marker) && isSafePathInside(marker, targetDir)) {
|
|
251
|
+
actions.push({ file: marker, action: 'delete_marker' });
|
|
252
|
+
if (!dryRun) {
|
|
253
|
+
try { fs.unlinkSync(marker); } catch {}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!dryRun) {
|
|
258
|
+
cleanEmptyDirs(targetDir);
|
|
259
|
+
try {
|
|
260
|
+
if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length === 0) {
|
|
261
|
+
fs.rmdirSync(targetDir);
|
|
262
|
+
}
|
|
263
|
+
} catch {}
|
|
264
|
+
|
|
265
|
+
delete ledger.installations[agentId];
|
|
266
|
+
saveLedger(ledger);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
success: true,
|
|
271
|
+
removedCount,
|
|
272
|
+
preservedCount,
|
|
273
|
+
targetDir,
|
|
274
|
+
dryRun,
|
|
275
|
+
actions
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function cleanEmptyDirs(dir) {
|
|
281
|
+
if (!fs.existsSync(dir)) return;
|
|
282
|
+
// Don't clean inside external symlinks
|
|
283
|
+
try {
|
|
284
|
+
const stat = fs.lstatSync(dir);
|
|
285
|
+
if (stat.isSymbolicLink()) return;
|
|
286
|
+
} catch {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
293
|
+
const sub = path.join(dir, entry.name);
|
|
294
|
+
cleanEmptyDirs(sub);
|
|
295
|
+
try {
|
|
296
|
+
if (fs.readdirSync(sub).length === 0) {
|
|
297
|
+
fs.rmdirSync(sub);
|
|
298
|
+
}
|
|
299
|
+
} catch {}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export function getPlatformInfo() {
|
|
6
|
+
const p = os.platform();
|
|
7
|
+
const a = os.arch();
|
|
8
|
+
|
|
9
|
+
let target = null;
|
|
10
|
+
let supported = true;
|
|
11
|
+
let note = '';
|
|
12
|
+
|
|
13
|
+
if (p === 'darwin') {
|
|
14
|
+
if (a === 'arm64') {
|
|
15
|
+
target = 'aarch64-apple-darwin';
|
|
16
|
+
} else {
|
|
17
|
+
supported = false;
|
|
18
|
+
note = 'macOS x86_64 (Intel) is not supported for standalone binary releases. Under Rosetta, run from a native ARM shell.';
|
|
19
|
+
}
|
|
20
|
+
} else if (p === 'linux') {
|
|
21
|
+
if (a === 'x64') {
|
|
22
|
+
target = 'x86_64-unknown-linux-musl';
|
|
23
|
+
} else if (a === 'arm64') {
|
|
24
|
+
target = 'aarch64-unknown-linux-musl';
|
|
25
|
+
} else {
|
|
26
|
+
supported = false;
|
|
27
|
+
note = `Linux architecture ${a} is not supported. Supported: x64, arm64.`;
|
|
28
|
+
}
|
|
29
|
+
} else if (p === 'win32') {
|
|
30
|
+
if (a === 'x64') {
|
|
31
|
+
target = 'x86_64-pc-windows-msvc';
|
|
32
|
+
} else {
|
|
33
|
+
supported = false;
|
|
34
|
+
note = `Windows architecture ${a} is not supported. Supported: x64.`;
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
supported = false;
|
|
38
|
+
note = `Operating system ${p} is not supported.`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isWindows = p === 'win32';
|
|
42
|
+
const binaryName = isWindows ? 'sensecraft-engine.exe' : 'sensecraft-engine';
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
platform: p,
|
|
46
|
+
arch: a,
|
|
47
|
+
target,
|
|
48
|
+
supported,
|
|
49
|
+
note,
|
|
50
|
+
isWindows,
|
|
51
|
+
binaryName
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getScHome() {
|
|
56
|
+
return process.env.SC_HOME || path.join(os.homedir(), '.sensecraft');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getEngineBinDir() {
|
|
60
|
+
return path.join(getScHome(), 'bin');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getEngineBinaryPath() {
|
|
64
|
+
const { binaryName } = getPlatformInfo();
|
|
65
|
+
return path.join(getEngineBinDir(), binaryName);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getContentDir() {
|
|
69
|
+
return path.join(getScHome(), 'content');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getConfiguredEnginePath() {
|
|
73
|
+
const pointerFile = path.join(getScHome(), 'engine.json');
|
|
74
|
+
if (fs.existsSync(pointerFile)) {
|
|
75
|
+
try {
|
|
76
|
+
const data = JSON.parse(fs.readFileSync(pointerFile, 'utf8'));
|
|
77
|
+
if (data && data.bin && fs.existsSync(data.bin) && isExecutable(data.bin)) {
|
|
78
|
+
return path.resolve(data.bin);
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
return path.resolve(getEngineBinaryPath());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function setConfiguredEnginePath(binPath, version = null, source = null) {
|
|
86
|
+
const pointerFile = path.join(getScHome(), 'engine.json');
|
|
87
|
+
const dir = path.dirname(pointerFile);
|
|
88
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
89
|
+
const payload = {
|
|
90
|
+
schema_version: 1,
|
|
91
|
+
bin: path.resolve(binPath),
|
|
92
|
+
version: version || 'unknown',
|
|
93
|
+
source: source || 'unknown',
|
|
94
|
+
updated_at: new Date().toISOString()
|
|
95
|
+
};
|
|
96
|
+
const tempFile = path.join(dir, `.engine.tmp-${Date.now()}`);
|
|
97
|
+
fs.writeFileSync(tempFile, JSON.stringify(payload, null, 2), 'utf8');
|
|
98
|
+
fs.renameSync(tempFile, pointerFile);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isExecutable(filePath) {
|
|
102
|
+
try {
|
|
103
|
+
const stat = fs.statSync(filePath);
|
|
104
|
+
if (!stat.isFile()) return false;
|
|
105
|
+
if (os.platform() === 'win32') {
|
|
106
|
+
return filePath.toLowerCase().endsWith('.exe') || filePath.toLowerCase().endsWith('.cmd');
|
|
107
|
+
}
|
|
108
|
+
// Unix: check execute permissions (owner/group/others)
|
|
109
|
+
return Boolean(stat.mode & 0o111);
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seeed-studio/solution-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-friction installer and CLI runner for SenseCraft Engine and AI Agent Skills (Work Buddy, Claude Code, Codex, CodeBuddy)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/installer.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"solution-cli": "bin/cli.js",
|
|
9
|
+
"sensecraft-solution-cli": "bin/cli.js",
|
|
10
|
+
"sensecraft": "bin/cli.js",
|
|
11
|
+
"sensecraft-engine": "bin/runner.js",
|
|
12
|
+
"sensecraft-skill": "bin/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"lib",
|
|
20
|
+
"skills",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test tests/cli.test.mjs",
|
|
25
|
+
"install:self": "node bin/cli.js install",
|
|
26
|
+
"status": "node bin/cli.js status",
|
|
27
|
+
"doctor": "node bin/cli.js doctor"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"seeed-studio",
|
|
31
|
+
"sensecraft",
|
|
32
|
+
"sensecraft-solution",
|
|
33
|
+
"iot",
|
|
34
|
+
"workbuddy",
|
|
35
|
+
"claude-code",
|
|
36
|
+
"codex",
|
|
37
|
+
"codebuddy",
|
|
38
|
+
"skills",
|
|
39
|
+
"edge-ai",
|
|
40
|
+
"jetson",
|
|
41
|
+
"raspberry-pi"
|
|
42
|
+
],
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/Seeed-Studio/app_collaboration.git",
|
|
46
|
+
"directory": "packages/solution-cli"
|
|
47
|
+
},
|
|
48
|
+
"author": "Seeed Studio",
|
|
49
|
+
"license": "Apache-2.0",
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sensecraft
|
|
3
|
+
description: Deploy, manage, and troubleshoot SenseCraft / Seeed Studio edge IoT solutions (智慧农业, 摔倒检测, 边缘安全, 智慧仓库, 语音助手, people counting, face recognition 等) on hardware devices (NVIDIA Jetson, Raspberry Pi 树莓派, reComputer, reCamera, SenseCAP Watcher, Grove Vision AI, ESP32) using the `sensecraft-engine` command-line engine (no desktop app needed). Use whenever the user asks to deploy (部署), install (安装方案), test, inspect, or troubleshoot a SenseCraft edge IoT solution or configure devices and sensors. Changing a solution's configuration needs the desktop app (references/app-only.md).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# SenseCraft Solution
|
|
7
|
+
|
|
8
|
+
This skill drives the `sensecraft-engine` command-line engine. It works in any
|
|
9
|
+
agent that can read files and run shell commands. Every step is a
|
|
10
|
+
`sensecraft-engine` command; the engine starts its own background instance when
|
|
11
|
+
a command needs one. Do not call the instance's HTTP port with `curl`: use the
|
|
12
|
+
CLI, and `sensecraft-engine api <METHOD> <path>` for anything without its own
|
|
13
|
+
command.
|
|
14
|
+
|
|
15
|
+
Capabilities that need the **SenseCraft Solution desktop app** (editing a
|
|
16
|
+
solution's configuration, the app's `/api/ai/context`, diagnosing through the
|
|
17
|
+
app's logs) are in `references/app-only.md`. Use them only when the user has
|
|
18
|
+
the app running.
|
|
19
|
+
|
|
20
|
+
## Host platform boundary
|
|
21
|
+
|
|
22
|
+
The standalone engine supports four host platforms: macOS ARM
|
|
23
|
+
(`aarch64-apple-darwin`), Linux ARM (`aarch64-unknown-linux-musl`), Linux
|
|
24
|
+
x86_64 (`x86_64-unknown-linux-musl`), and Windows x86_64
|
|
25
|
+
(`x86_64-pc-windows-msvc`). macOS x86_64 is not maintained; under Rosetta,
|
|
26
|
+
run the installer from a native ARM shell.
|
|
27
|
+
|
|
28
|
+
`docker_deploy` target `local` requires a supported Docker host. Windows native
|
|
29
|
+
`docker_local` is intentionally unsupported by both the Python and Rust
|
|
30
|
+
deployers; a Windows CLI can still use a `remote` target over SSH to a Linux
|
|
31
|
+
Docker host. WSL uses the Linux engine binary and Linux host behavior; do not
|
|
32
|
+
describe a WSL run as Windows-native local Docker support. USB device steps
|
|
33
|
+
remain hardware-dependent and require the user to connect and confirm the
|
|
34
|
+
device.
|
|
35
|
+
|
|
36
|
+
On macOS, local Docker uses the literal `docker` command found by the engine's
|
|
37
|
+
process environment. If Docker Desktop's CLI is only inside its application
|
|
38
|
+
bundle, start a new engine process with a temporary `PATH` containing that
|
|
39
|
+
bundle's `bin` directory; do not change global shell configuration or assume an
|
|
40
|
+
existing engine daemon inherits the new path. Verify the new engine instance
|
|
41
|
+
with `doctor --json` before selecting a local target. A running Docker Desktop
|
|
42
|
+
daemon is a host prerequisite, not evidence that a deployment has passed.
|
|
43
|
+
|
|
44
|
+
## Rules
|
|
45
|
+
|
|
46
|
+
1. **Credentials and secrets:** never invent a host, username or password —
|
|
47
|
+
ask the user. Replace passwords, tokens, API keys and any other secret a
|
|
48
|
+
deployment prints (for example a gateway token) with `<REDACTED>` in
|
|
49
|
+
anything you show or log. Tell the user where to find the value (on the
|
|
50
|
+
device, in the solution's page, in the file that holds it) instead of
|
|
51
|
+
printing it.
|
|
52
|
+
2. **What you may do without asking, and what needs the user's OK:**
|
|
53
|
+
|
|
54
|
+
| Action | Ask first? |
|
|
55
|
+
|---|---|
|
|
56
|
+
| Read deployment status and logs (`status`, `logs`), engine log files, `doctor`, `device list/ports/scan` | No |
|
|
57
|
+
| Read-only checks on a device over SSH (`docker ps`, `docker logs`, `df -h`, `ss -ltnp`, `ping`) | No |
|
|
58
|
+
| Edit the solution's edit copy (desktop app only, see `references/app-only.md`) | No — show the diff afterwards; the user sees it in the app as "locally modified" and can restore the official version in one click |
|
|
59
|
+
| Anything a person does with their hands: plug in or swap a cable, connect a servo, sit in front of a camera, walk across a counting line, move or stand near a robot arm | **Yes** — tell the user what to do and wait for them to confirm it is done |
|
|
60
|
+
| Commands that move hardware (`robot torque`, `robot actions test`) | **Yes** — the user confirms nobody is within reach first |
|
|
61
|
+
| Change anything on a device: stop/remove containers, delete files, install packages, change system settings; `--replace-existing` | **Yes** |
|
|
62
|
+
| Redeploy | First retry: no. Every later retry: **yes** |
|
|
63
|
+
| Cancel a running deployment | **Yes** — it can leave a device half-configured |
|
|
64
|
+
| Prepare an issue report (the user submits it) or open a pull request | **Yes** |
|
|
65
|
+
|
|
66
|
+
3. **At most 3 fix-and-retry rounds** per problem for deterministic failures
|
|
67
|
+
(wrong parameter, missing file, port taken, bad credentials, a container
|
|
68
|
+
that exits). After that, stop and give the user a report (what failed, what
|
|
69
|
+
you checked, what you changed). Transient registry or network errors follow
|
|
70
|
+
the backoff rule in `references/deploy.md` §6 and do not use up a round.
|
|
71
|
+
4. **Preserving official cache & Local auto-patching**: Never edit files directly under the official content cache directory (`~/.sensecraft/content/`). When fixing a solution-level bug (timeout too short, wrong port/env var, minor syntax/transfer glitch, broken health check):
|
|
72
|
+
- In CLI / Engine mode: copy the solution from `~/.sensecraft/content/solutions/<id>` into `./.sensecraft-work/solutions/<id>`, apply the minimal fix in that copy. **Crucial engine switch step**: Because an existing engine daemon instance retains its initial solution directory in memory, you MUST stop the running instance first: `sensecraft-engine daemon stop`. Then re-run deployment with `--solutions-dir ./.sensecraft-work/solutions` so the fresh engine instance picks up the patched files. Show the user the patch diff.
|
|
73
|
+
- If running with the Desktop App: use the app's edit copy mechanism (`references/app-only.md` §4).
|
|
74
|
+
5. **Reading output:** read the CLI's JSON output directly; it is small. If a
|
|
75
|
+
filter is needed, use `jq` only when it is already installed. Do not start
|
|
76
|
+
python, node or any other interpreter to parse output. Where a command has
|
|
77
|
+
a flag that prints just what you need (`--json`, `--preset`, `--device`,
|
|
78
|
+
`--since`), use it.
|
|
79
|
+
6. **Temp files:** keep request bodies and scratch files in the current
|
|
80
|
+
working directory under `./.sensecraft-work/`, never in `/tmp`. Remove the
|
|
81
|
+
directory when the task ends, also after a failure.
|
|
82
|
+
|
|
83
|
+
## Step 0 — Install the engine and the solutions (every task)
|
|
84
|
+
|
|
85
|
+
1. Check whether it is installed: `sensecraft-engine --version`. If the command
|
|
86
|
+
is not found, also try `~/.sensecraft/bin/sensecraft-engine --version`
|
|
87
|
+
(Windows: `%USERPROFILE%\.sensecraft\bin\sensecraft-engine.cmd`); the
|
|
88
|
+
installer puts it there and prints the `PATH` line to add.
|
|
89
|
+
2. Before running an installer, check its local prerequisites. On Unix run
|
|
90
|
+
`command -v curl || command -v wget`, `command -v sha256sum || command -v shasum`,
|
|
91
|
+
`command -v tar`, `command -v jq`, and `command -v minisign`. If wget is the
|
|
92
|
+
selected downloader, also require `wget --help | grep -- --compression` to
|
|
93
|
+
succeed; otherwise stop because the CDN may return gzip content. On Windows
|
|
94
|
+
PowerShell run `Get-Command irm`, `Get-Command Invoke-WebRequest`,
|
|
95
|
+
`Get-Command Expand-Archive`, `Get-Command ConvertFrom-Json`, and
|
|
96
|
+
`Get-Command minisign`. Windows uses PowerShell/.NET ZIP and JSON support;
|
|
97
|
+
it does not require `tar` or `jq`. If any check fails, report the missing
|
|
98
|
+
command and stop; do not install packages or start another interpreter.
|
|
99
|
+
`minisign` is required to verify the signed release manifest; Unix `jq` is
|
|
100
|
+
required by the shell installer to read it.
|
|
101
|
+
3. Not installed → install it together with the solutions (ask the user first,
|
|
102
|
+
it downloads a program):
|
|
103
|
+
|
|
104
|
+
**Recommended (Zero-friction via solution-cli, auto-detects existing app & agents)**:
|
|
105
|
+
```bash
|
|
106
|
+
npx @seeed-studio/solution-cli install -y
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Fallback (Standalone shell script when Node.js is not present)**:
|
|
110
|
+
```bash
|
|
111
|
+
curl --compressed -fsSL https://sensecraft-statics.seeed.cc/solution-app/engine/install.sh | sh -s -- --with-content
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
If `curl` is unavailable but `wget` passed the prerequisite check, use the
|
|
115
|
+
equivalent command:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
wget --compression=auto -qO- https://sensecraft-statics.seeed.cc/solution-app/engine/install.sh | sh -s -- --with-content
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Windows PowerShell:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
& ([scriptblock]::Create((irm https://sensecraft-statics.seeed.cc/solution-app/engine/install.ps1))) -WithContent
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
4. Solutions are downloaded separately from the engine:
|
|
128
|
+
|
|
129
|
+
| Command | Does |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `sensecraft-engine content status --offline --json` | Reads the local content store only; an empty store is a normal result with `installed: 0` and exit code 0 |
|
|
132
|
+
| `sensecraft-engine content status --json` | Compares local solutions with the online list |
|
|
133
|
+
| `sensecraft-engine content pull` | Downloads every solution (first run), or only `--solution <id>` |
|
|
134
|
+
| `sensecraft-engine content update` | Re-downloads installed solutions that changed online |
|
|
135
|
+
|
|
136
|
+
Run the offline status check before deciding whether content is installed.
|
|
137
|
+
If it returns exit code 0 with `installed: 0`, run `content pull`; do not
|
|
138
|
+
treat that status result as an error. A solution query such as `solution
|
|
139
|
+
list` or `deploy-info` exits with code 3 when no usable resource/solution
|
|
140
|
+
directory is available and tells you to run `content pull`. If a usable
|
|
141
|
+
directory exists but the requested solution ID is unknown, the query exits
|
|
142
|
+
with code 2. After `content pull` / `update`, if an engine instance is
|
|
143
|
+
already running it keeps the solutions it loaded at start: run
|
|
144
|
+
`sensecraft-engine daemon stop` when no deployment is running.
|
|
145
|
+
5. Device pre-checks on this computer: `sensecraft-engine doctor --json`. Each
|
|
146
|
+
check has `status` (`ok` / `warn` / `fail`) and a `fix` command. Show the
|
|
147
|
+
user any `fail` and its `fix`; running a `fix` changes this computer and
|
|
148
|
+
needs their OK.
|
|
149
|
+
|
|
150
|
+
Use `--lang zh` on commands that take `--lang` when the user writes in Chinese,
|
|
151
|
+
`--lang en` otherwise.
|
|
152
|
+
|
|
153
|
+
## Exit codes (all commands)
|
|
154
|
+
|
|
155
|
+
| Code | Meaning | Next step |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| 0 | Success / accepted | Continue |
|
|
158
|
+
| 1 | Deployment failed, cancelled or interrupted; for `counting` / `face` / `wizard` / `robot`: the device did not confirm | `references/diagnose.md` |
|
|
159
|
+
| 2 | Invalid arguments or request. A `missing_params` error includes `missing[]`; an unknown ID or malformed JSON is also code 2 and may have no `missing[]` | Ask for each field only when `missing[]` is present; otherwise inspect the ID, JSON shape and parameter spelling, then correct or ask for the specific value |
|
|
160
|
+
| 3 | Environment or conflict: a solution query has no usable resource/solution directory (`content pull`), device busy with another deployment (409), engine instance shutting down or incompatible, serial port busy | Read the message; pull content or fix the environment, then run again. An empty `content status --offline` store is exit 0, not this case |
|
|
161
|
+
| 4 | This step needs the desktop app (deployer not in the engine) | Tell the user to deploy this solution from the SenseCraft Solution app |
|
|
162
|
+
| 5 | Internal engine error | Engine bug: `references/diagnose.md` §6 |
|
|
163
|
+
|
|
164
|
+
## Pick the task
|
|
165
|
+
|
|
166
|
+
| The user wants to... | Read |
|
|
167
|
+
|---|---|
|
|
168
|
+
| Deploy a solution | `references/deploy.md` |
|
|
169
|
+
| Finish a step that reported `verify_required` (dashboards, counting lines, face enrollment, servo setup, robot arm) | `references/verify.md` |
|
|
170
|
+
| Find out why a deployment failed or a deployed solution misbehaves | `references/diagnose.md` |
|
|
171
|
+
| Deploy on a site without internet (prepare an offline package first) | `references/offline.md` |
|
|
172
|
+
| Change a solution's configuration, or use the desktop app's API | `references/app-only.md` (requires the desktop app) |
|
|
173
|
+
|
|
174
|
+
They connect: **a failed deployment goes straight to `diagnose.md`** without
|
|
175
|
+
stopping to ask; a `verify_required` event goes to `verify.md`; the retry goes
|
|
176
|
+
back to `deploy.md` for the failed device only.
|