@silicaclaw/cli 2026.3.20-3 → 2026.3.20-4

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.
@@ -1,6 +1,6 @@
1
1
  import express, { NextFunction, Request, Response } from "express";
2
2
  import cors from "cors";
3
- import { execFile, spawnSync } from "child_process";
3
+ import { execFile, spawn, spawnSync } from "child_process";
4
4
  import { resolve } from "path";
5
5
  import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "fs";
6
6
  import { createHash } from "crypto";
@@ -90,6 +90,7 @@ const DEFAULT_BRIDGE_API_BASE = defaults.bridge.api_base;
90
90
  const OPENCLAW_GATEWAY_PORT = defaults.ports.openclaw_gateway;
91
91
  const OPENCLAW_GATEWAY_URL = `http://${OPENCLAW_GATEWAY_HOST}:${OPENCLAW_GATEWAY_PORT}/`;
92
92
  const NETWORK_PEER_REMOVE_AFTER_MS = Number(process.env.NETWORK_PEER_REMOVE_AFTER_MS || 180_000);
93
+ const DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT = Number(process.env.DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT || 1000);
93
94
  const NETWORK_UDP_BIND_ADDRESS = process.env.NETWORK_UDP_BIND_ADDRESS || "0.0.0.0";
94
95
  const NETWORK_UDP_BROADCAST_ADDRESS = process.env.NETWORK_UDP_BROADCAST_ADDRESS || "255.255.255.255";
95
96
  const NETWORK_PEER_ID = process.env.NETWORK_PEER_ID;
@@ -157,6 +158,10 @@ function normalizeVersionText(value: unknown): string {
157
158
  return text.startsWith("v") ? text.slice(1) : text;
158
159
  }
159
160
 
161
+ function formatBytesToMiB(value: number): number {
162
+ return Math.round((value / (1024 * 1024)) * 10) / 10;
163
+ }
164
+
160
165
  function tokenizeVersion(value: unknown): Array<number | string> {
161
166
  return normalizeVersionText(value)
162
167
  .split(/[^0-9A-Za-z]+/)
@@ -186,6 +191,10 @@ function compareVersionTokens(left: unknown, right: unknown): number {
186
191
  return 0;
187
192
  }
188
193
 
194
+ function userNpmCacheDir(): string {
195
+ return resolve(homedir(), ".silicaclaw", "npm-cache");
196
+ }
197
+
189
198
  function resolveWorkspaceRoot(cwd = process.cwd()): string {
190
199
  if (existsSync(resolve(cwd, "apps", "local-console", "package.json"))) {
191
200
  return cwd;
@@ -1201,6 +1210,7 @@ export class LocalNodeService {
1201
1210
  const relayCapable = this.adapterMode === "webrtc-preview" || this.adapterMode === "relay-preview";
1202
1211
  const peers: Array<{ status?: string }> = diagnostics?.peers?.items ?? [];
1203
1212
  const online = peers.filter((peer: { status?: string }) => peer.status === "online").length;
1213
+ const memory = process.memoryUsage();
1204
1214
 
1205
1215
  return {
1206
1216
  adapter: this.adapterMode,
@@ -1225,6 +1235,23 @@ export class LocalNodeService {
1225
1235
  adapter_stats: diagnostics?.stats ?? null,
1226
1236
  adapter_transport_stats: diagnostics?.transport_stats ?? null,
1227
1237
  adapter_discovery_stats: diagnostics?.discovery_stats ?? null,
1238
+ runtime_diagnostics: {
1239
+ memory_mib: {
1240
+ rss: formatBytesToMiB(memory.rss),
1241
+ heap_used: formatBytesToMiB(memory.heapUsed),
1242
+ heap_total: formatBytesToMiB(memory.heapTotal),
1243
+ external: formatBytesToMiB(memory.external),
1244
+ },
1245
+ directory: {
1246
+ profile_count: Object.keys(this.directory.profiles).length,
1247
+ presence_count: Object.keys(this.directory.presence).length,
1248
+ index_key_count: Object.keys(this.directory.index).length,
1249
+ },
1250
+ social: {
1251
+ message_count: this.socialMessages.length,
1252
+ observation_count: this.socialMessageObservations.length,
1253
+ },
1254
+ },
1228
1255
  adapter_diagnostics_summary: relayCapable || diagnostics
1229
1256
  ? {
1230
1257
  started: this.networkStarted,
@@ -1341,6 +1368,88 @@ export class LocalNodeService {
1341
1368
  };
1342
1369
  }
1343
1370
 
1371
+ getAppUpdateStatus() {
1372
+ const currentVersion = normalizeVersionText(this.appVersion) || "unknown";
1373
+ const fallback = {
1374
+ current_version: currentVersion,
1375
+ latest_version: currentVersion,
1376
+ update_available: false,
1377
+ channel: "latest",
1378
+ platform: process.platform,
1379
+ checked_at: Date.now(),
1380
+ can_update: true,
1381
+ check_error: null as string | null,
1382
+ };
1383
+ try {
1384
+ const result = spawnSync("npm", ["view", "@silicaclaw/cli", "dist-tags", "--json"], {
1385
+ cwd: this.projectRoot,
1386
+ encoding: "utf8",
1387
+ env: {
1388
+ ...process.env,
1389
+ SILICACLAW_WORKSPACE_DIR: this.projectRoot,
1390
+ SILICACLAW_APP_DIR: this.workspaceRoot,
1391
+ npm_config_cache: process.env.npm_config_cache || userNpmCacheDir(),
1392
+ },
1393
+ });
1394
+ if ((result.status ?? 1) !== 0) {
1395
+ return {
1396
+ ...fallback,
1397
+ check_error: String(result.stderr || result.stdout || "npm view failed").trim() || "npm view failed",
1398
+ };
1399
+ }
1400
+ const tags = JSON.parse(String(result.stdout || "{}").trim() || "{}") as { latest?: string };
1401
+ const latestVersion = normalizeVersionText(tags.latest || currentVersion) || currentVersion;
1402
+ return {
1403
+ ...fallback,
1404
+ latest_version: latestVersion,
1405
+ update_available: compareVersionTokens(latestVersion, currentVersion) > 0,
1406
+ };
1407
+ } catch (error) {
1408
+ return {
1409
+ ...fallback,
1410
+ check_error: error instanceof Error ? error.message : String(error),
1411
+ };
1412
+ }
1413
+ }
1414
+
1415
+ startAppUpdate(): { started: boolean; target_version: string; platform: string; reason?: string } {
1416
+ const status = this.getAppUpdateStatus();
1417
+ if (!status.update_available || !status.latest_version) {
1418
+ return {
1419
+ started: false,
1420
+ target_version: status.latest_version || status.current_version,
1421
+ platform: process.platform,
1422
+ reason: status.check_error || "already_current",
1423
+ };
1424
+ }
1425
+ const scriptPath = resolve(this.workspaceRoot, "scripts", "silicaclaw-cli.mjs");
1426
+ if (!existsSync(scriptPath)) {
1427
+ return {
1428
+ started: false,
1429
+ target_version: status.latest_version,
1430
+ platform: process.platform,
1431
+ reason: "missing_cli_script",
1432
+ };
1433
+ }
1434
+ const child = spawn(process.execPath, [scriptPath, "update"], {
1435
+ cwd: this.projectRoot,
1436
+ detached: true,
1437
+ stdio: "ignore",
1438
+ env: {
1439
+ ...process.env,
1440
+ SILICACLAW_WORKSPACE_DIR: this.projectRoot,
1441
+ SILICACLAW_APP_DIR: this.workspaceRoot,
1442
+ npm_config_cache: process.env.npm_config_cache || userNpmCacheDir(),
1443
+ },
1444
+ });
1445
+ child.unref();
1446
+ return {
1447
+ started: true,
1448
+ target_version: status.latest_version,
1449
+ platform: process.platform,
1450
+ };
1451
+ }
1452
+
1344
1453
  getIntegrationSummary() {
1345
1454
  const status = this.getIntegrationStatus();
1346
1455
  const runtimeGenerated = Boolean(this.socialRuntime && this.socialRuntime.last_loaded_at > 0);
@@ -2670,9 +2779,66 @@ export class LocalNodeService {
2670
2779
  this.networkReconnectDelayMs = Math.min(30_000, Math.max(5_000, Math.floor(delayMs * 1.5)));
2671
2780
  }
2672
2781
 
2782
+ private pruneRemoteProfilesInMemory(now = Date.now()): number {
2783
+ if (!Number.isFinite(DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT) || DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT <= 0) {
2784
+ return 0;
2785
+ }
2786
+ const selfAgentId = this.profile?.agent_id || this.identity?.agent_id || "";
2787
+ const remoteProfiles = Object.values(this.directory.profiles).filter((profile) => profile.agent_id !== selfAgentId);
2788
+ if (remoteProfiles.length <= DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT) {
2789
+ return 0;
2790
+ }
2791
+
2792
+ const onlineRemoteProfiles = remoteProfiles.filter((profile) =>
2793
+ isAgentOnline(this.directory.presence[profile.agent_id], now, PRESENCE_TTL_MS)
2794
+ );
2795
+ const offlineRemoteProfiles = remoteProfiles
2796
+ .filter((profile) => !isAgentOnline(this.directory.presence[profile.agent_id], now, PRESENCE_TTL_MS))
2797
+ .sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
2798
+
2799
+ const keepOfflineCount = Math.max(0, DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT - onlineRemoteProfiles.length);
2800
+ const keptRemoteProfiles = [
2801
+ ...onlineRemoteProfiles,
2802
+ ...offlineRemoteProfiles.slice(0, keepOfflineCount),
2803
+ ];
2804
+ const keptRemoteIds = new Set(keptRemoteProfiles.map((profile) => profile.agent_id));
2805
+ const removedIds = remoteProfiles
2806
+ .map((profile) => profile.agent_id)
2807
+ .filter((agentId) => !keptRemoteIds.has(agentId));
2808
+ if (removedIds.length === 0) {
2809
+ return 0;
2810
+ }
2811
+
2812
+ const next = createEmptyDirectoryState();
2813
+ const selfProfile = selfAgentId ? this.directory.profiles[selfAgentId] : null;
2814
+ if (selfProfile) {
2815
+ next.profiles[selfAgentId] = selfProfile;
2816
+ const selfPresence = this.directory.presence[selfAgentId];
2817
+ if (typeof selfPresence === "number" && Number.isFinite(selfPresence)) {
2818
+ next.presence[selfAgentId] = selfPresence;
2819
+ }
2820
+ const rebuilt = rebuildIndexForProfile(next, selfProfile);
2821
+ next.index = rebuilt.index;
2822
+ }
2823
+
2824
+ for (const profile of keptRemoteProfiles) {
2825
+ next.profiles[profile.agent_id] = profile;
2826
+ const seenAt = this.directory.presence[profile.agent_id];
2827
+ if (typeof seenAt === "number" && Number.isFinite(seenAt)) {
2828
+ next.presence[profile.agent_id] = seenAt;
2829
+ }
2830
+ const rebuilt = rebuildIndexForProfile(next, profile);
2831
+ next.index = rebuilt.index;
2832
+ }
2833
+
2834
+ this.directory = dedupeIndex(next);
2835
+ return removedIds.length;
2836
+ }
2837
+
2673
2838
  private compactCacheInMemory(): number {
2674
2839
  const cleaned = cleanupExpiredPresence(this.directory, Date.now(), PRESENCE_TTL_MS);
2675
2840
  this.directory = dedupeIndex(cleaned.state);
2841
+ this.pruneRemoteProfilesInMemory();
2676
2842
  return cleaned.removed;
2677
2843
  }
2678
2844
 
@@ -3411,6 +3577,48 @@ export async function main() {
3411
3577
  sendOk(res, node.getRuntimePaths());
3412
3578
  });
3413
3579
 
3580
+ app.get("/api/app/update-status", (_req, res) => {
3581
+ sendOk(res, node.getAppUpdateStatus());
3582
+ });
3583
+
3584
+ app.post(
3585
+ "/api/app/update",
3586
+ asyncRoute(async (_req, res) => {
3587
+ const status = node.getAppUpdateStatus();
3588
+ if (!status.update_available || !status.latest_version) {
3589
+ sendOk(
3590
+ res,
3591
+ {
3592
+ started: false,
3593
+ current_version: status.current_version,
3594
+ latest_version: status.latest_version,
3595
+ platform: status.platform,
3596
+ reason: status.check_error || "already_current",
3597
+ },
3598
+ { message: "Already on the latest version" }
3599
+ );
3600
+ return;
3601
+ }
3602
+ sendOk(
3603
+ res,
3604
+ {
3605
+ started: true,
3606
+ current_version: status.current_version,
3607
+ target_version: status.latest_version,
3608
+ platform: status.platform,
3609
+ },
3610
+ { message: `Updating to ${status.latest_version}` }
3611
+ );
3612
+ setTimeout(() => {
3613
+ try {
3614
+ node.startAppUpdate();
3615
+ } catch {
3616
+ // best effort after response has been sent
3617
+ }
3618
+ }, 150);
3619
+ })
3620
+ );
3621
+
3414
3622
  app.put(
3415
3623
  "/api/profile",
3416
3624
  asyncRoute(async (req, res) => {
@@ -1 +1 @@
1
- 2026.3.20-beta.3
1
+ 2026.3.20-beta.4
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "silicaclaw-broadcast",
3
- "version": "2026.3.20-beta.3",
3
+ "version": "2026.3.20-beta.4",
4
4
  "display_name": "SilicaClaw Broadcast",
5
5
  "description": "Official OpenClaw skill for a bounded local SilicaClaw broadcast workflow: read public broadcasts, publish public broadcasts, and optionally forward owner-relevant summaries through OpenClaw's native channel.",
6
6
  "entrypoints": {
@@ -1 +1 @@
1
- 2026.3.20-beta.1
1
+ 2026.3.20-beta.2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "silicaclaw-owner-push",
3
- "version": "2026.3.20-beta.1",
3
+ "version": "2026.3.20-beta.2",
4
4
  "display_name": "SilicaClaw Owner Push",
5
5
  "description": "Official OpenClaw skill for a bounded local SilicaClaw monitoring workflow: watch public broadcasts, filter owner-relevant updates, and push concise summaries through OpenClaw's native owner channel.",
6
6
  "entrypoints": {
@@ -19,6 +19,7 @@ export OPENCLAW_FORWARD_INCLUDE="approval,failed,blocked,completed"
19
19
  export OPENCLAW_FORWARD_EXCLUDE="heartbeat,debug"
20
20
  export OPENCLAW_FORWARDER_INTERVAL_MS="5000"
21
21
  export OPENCLAW_FORWARDER_LIMIT="30"
22
+ export OPENCLAW_FORWARD_LATEST_ONLY="true"
22
23
  ```
23
24
 
24
25
  ## Persistent cursor
@@ -33,6 +34,8 @@ Override it with:
33
34
  export OPENCLAW_OWNER_FORWARD_STATE_PATH="/custom/path/silicaclaw-owner-push.json"
34
35
  ```
35
36
 
37
+ The state file now also stores the last pushed message timestamp and message id so the forwarder can push only the latest qualifying message after that cursor and skip older messages permanently.
38
+
36
39
  ## Typical topology
37
40
 
38
41
  - A machine: runs SilicaClaw and publishes public broadcasts
@@ -12,6 +12,7 @@ const OWNER_FORWARD_CMD = String(process.env.OPENCLAW_OWNER_FORWARD_CMD || "").t
12
12
  const STATE_PATH = resolve(
13
13
  String(process.env.OPENCLAW_OWNER_FORWARD_STATE_PATH || resolve(homedir(), ".openclaw", "workspace", "state", "silicaclaw-owner-push.json"))
14
14
  );
15
+ const LATEST_ONLY = String(process.env.OPENCLAW_FORWARD_LATEST_ONLY || "true").trim().toLowerCase() !== "false";
15
16
  const ONCE = process.argv.includes("--once");
16
17
  const VERBOSE = process.argv.includes("--verbose");
17
18
 
@@ -61,14 +62,24 @@ function loadState() {
61
62
  return {
62
63
  seen_ids: [],
63
64
  pushed_at: {},
65
+ last_pushed_created_at: 0,
66
+ last_pushed_message_id: "",
64
67
  };
65
68
  }
66
69
  try {
67
- return JSON.parse(readFileSync(STATE_PATH, "utf8"));
70
+ const parsed = JSON.parse(readFileSync(STATE_PATH, "utf8"));
71
+ return {
72
+ seen_ids: Array.isArray(parsed?.seen_ids) ? parsed.seen_ids : [],
73
+ pushed_at: parsed?.pushed_at && typeof parsed.pushed_at === "object" ? parsed.pushed_at : {},
74
+ last_pushed_created_at: Number(parsed?.last_pushed_created_at || 0) || 0,
75
+ last_pushed_message_id: String(parsed?.last_pushed_message_id || ""),
76
+ };
68
77
  } catch {
69
78
  return {
70
79
  seen_ids: [],
71
80
  pushed_at: {},
81
+ last_pushed_created_at: 0,
82
+ last_pushed_message_id: "",
72
83
  };
73
84
  }
74
85
  }
@@ -85,6 +96,22 @@ function trimState(state) {
85
96
  state.pushed_at = Object.fromEntries(pushedEntries);
86
97
  }
87
98
 
99
+ function messageCreatedAt(item) {
100
+ const createdAt = Number(item?.created_at || 0);
101
+ return Number.isFinite(createdAt) && createdAt > 0 ? createdAt : 0;
102
+ }
103
+
104
+ function isNewerThanCursor(item, state) {
105
+ const createdAt = messageCreatedAt(item);
106
+ const lastCreatedAt = Number(state.last_pushed_created_at || 0) || 0;
107
+ const messageId = String(item?.message_id || "").trim();
108
+ const lastMessageId = String(state.last_pushed_message_id || "").trim();
109
+ if (createdAt > lastCreatedAt) return true;
110
+ if (createdAt < lastCreatedAt) return false;
111
+ if (!createdAt) return !state.seen_ids.includes(messageId);
112
+ return Boolean(messageId) && messageId !== lastMessageId && !state.seen_ids.includes(messageId);
113
+ }
114
+
88
115
  function shouldWatchTopic(message) {
89
116
  if (!TOPIC_FILTERS.length) return true;
90
117
  return TOPIC_FILTERS.includes(String(message?.topic || "global").toLowerCase());
@@ -165,29 +192,60 @@ function dispatchToOwner(route, summary, message) {
165
192
  async function pollOnce(state) {
166
193
  const payload = await request(`/api/openclaw/bridge/messages?limit=${LIMIT}`);
167
194
  const items = Array.isArray(payload?.items) ? payload.items.slice().reverse() : [];
195
+ const candidates = [];
168
196
 
169
197
  for (const item of items) {
170
198
  const messageId = String(item?.message_id || "").trim();
171
199
  if (!messageId) continue;
172
- if (state.seen_ids.includes(messageId)) continue;
173
-
174
- state.seen_ids.push(messageId);
200
+ if (!isNewerThanCursor(item, state)) {
201
+ if (!state.seen_ids.includes(messageId)) {
202
+ state.seen_ids.push(messageId);
203
+ }
204
+ continue;
205
+ }
175
206
 
176
207
  if (!shouldWatchTopic(item)) {
208
+ state.seen_ids.push(messageId);
177
209
  if (VERBOSE) console.log(`skip topic: ${messageId}`);
178
210
  continue;
179
211
  }
180
212
 
181
213
  const route = scoreRoute(item);
182
214
  if (route === "ignore") {
215
+ state.seen_ids.push(messageId);
183
216
  if (VERBOSE) console.log(`ignore low-signal: ${messageId}`);
184
217
  continue;
185
218
  }
186
219
 
187
- const summary = summarizeForOwner(item);
188
- await dispatchToOwner(route, summary, item);
189
- state.pushed_at[messageId] = new Date().toISOString();
190
- if (VERBOSE) console.log(`pushed to owner: ${messageId}`);
220
+ candidates.push({ item, messageId, route, createdAt: messageCreatedAt(item) });
221
+ }
222
+
223
+ const selected = LATEST_ONLY
224
+ ? candidates.sort((left, right) => {
225
+ if (left.createdAt !== right.createdAt) return right.createdAt - left.createdAt;
226
+ return left.messageId.localeCompare(right.messageId);
227
+ })[0] || null
228
+ : null;
229
+
230
+ const toPush = LATEST_ONLY ? (selected ? [selected] : []) : candidates;
231
+
232
+ for (const candidate of toPush) {
233
+ const summary = summarizeForOwner(candidate.item);
234
+ await dispatchToOwner(candidate.route, summary, candidate.item);
235
+ state.pushed_at[candidate.messageId] = new Date().toISOString();
236
+ state.last_pushed_created_at = candidate.createdAt || Date.now();
237
+ state.last_pushed_message_id = candidate.messageId;
238
+ if (VERBOSE) console.log(`pushed to owner: ${candidate.messageId}`);
239
+ }
240
+
241
+ if (LATEST_ONLY && selected) {
242
+ for (const candidate of candidates) {
243
+ state.seen_ids.push(candidate.messageId);
244
+ }
245
+ } else {
246
+ for (const candidate of candidates) {
247
+ state.seen_ids.push(candidate.messageId);
248
+ }
191
249
  }
192
250
 
193
251
  trimState(state);
@@ -199,6 +257,7 @@ async function main() {
199
257
  if (VERBOSE) {
200
258
  console.log(`SilicaClaw owner push watching ${API_BASE}`);
201
259
  console.log(`State file: ${STATE_PATH}`);
260
+ console.log(`Latest-only mode: ${LATEST_ONLY ? "on" : "off"}`);
202
261
  }
203
262
 
204
263
  do {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silicaclaw/cli",
3
- "version": "2026.3.20-3",
3
+ "version": "2026.3.20-4",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -224,10 +224,12 @@ function shellInitTargets() {
224
224
  shell.endsWith("/bash") ||
225
225
  process.env.BASH_VERSION ||
226
226
  existsSync(resolve(home, ".bashrc")) ||
227
- existsSync(resolve(home, ".bash_profile"))
227
+ existsSync(resolve(home, ".bash_profile")) ||
228
+ existsSync(resolve(home, ".profile"))
228
229
  ) {
229
230
  add(resolve(home, ".bashrc"));
230
231
  add(resolve(home, ".bash_profile"));
232
+ add(resolve(home, ".profile"));
231
233
  }
232
234
 
233
235
  if (targets.length === 0) {
@@ -280,6 +282,7 @@ function installPersistentCommand(specifier = readPackageVersion()) {
280
282
  kv("Command", "silicaclaw");
281
283
  console.log("");
282
284
  kv("Activate", `source "${envFile}"`);
285
+ kv("Current shell", "run Activate now if `silicaclaw` is not found yet");
283
286
  if (configuredFiles.length > 0) {
284
287
  kv("Startup", "configured");
285
288
  } else {
@@ -74,6 +74,11 @@ function readJson(file) {
74
74
  }
75
75
  }
76
76
 
77
+ function readPackageVersionFrom(dir) {
78
+ const pkg = readJson(resolve(dir, "package.json"));
79
+ return String(pkg?.version || "latest").trim() || "latest";
80
+ }
81
+
77
82
  function isSilicaClawDir(dir) {
78
83
  const pkgPath = join(dir, "package.json");
79
84
  if (!existsSync(pkgPath)) return false;
@@ -144,6 +149,107 @@ function ensureStateDir() {
144
149
  mkdirSync(STATE_DIR, { recursive: true });
145
150
  }
146
151
 
152
+ function userShimDir() {
153
+ return join(homedir(), ".silicaclaw", "bin");
154
+ }
155
+
156
+ function userShimPath() {
157
+ return join(userShimDir(), "silicaclaw");
158
+ }
159
+
160
+ function userEnvFile() {
161
+ return join(homedir(), ".silicaclaw", "env.sh");
162
+ }
163
+
164
+ function userNpmCacheDir() {
165
+ return join(homedir(), ".silicaclaw", "npm-cache");
166
+ }
167
+
168
+ function preferredShellRcFile() {
169
+ const shell = String(process.env.SHELL || "");
170
+ if (shell.endsWith("/zsh")) return resolve(homedir(), ".zshrc");
171
+ if (shell.endsWith("/bash")) return resolve(homedir(), ".bashrc");
172
+ if (process.env.ZSH_VERSION) return resolve(homedir(), ".zshrc");
173
+ return resolve(homedir(), ".bashrc");
174
+ }
175
+
176
+ function shellInitTargets() {
177
+ const home = homedir();
178
+ const shell = String(process.env.SHELL || "");
179
+ const targets = [];
180
+ const add = (filePath) => {
181
+ if (!targets.includes(filePath)) targets.push(filePath);
182
+ };
183
+
184
+ if (shell.endsWith("/zsh") || process.env.ZSH_VERSION || existsSync(resolve(home, ".zshrc"))) {
185
+ add(resolve(home, ".zshrc"));
186
+ }
187
+
188
+ if (
189
+ shell.endsWith("/bash") ||
190
+ process.env.BASH_VERSION ||
191
+ existsSync(resolve(home, ".bashrc")) ||
192
+ existsSync(resolve(home, ".bash_profile")) ||
193
+ existsSync(resolve(home, ".profile"))
194
+ ) {
195
+ add(resolve(home, ".bashrc"));
196
+ add(resolve(home, ".bash_profile"));
197
+ add(resolve(home, ".profile"));
198
+ }
199
+
200
+ if (targets.length === 0) {
201
+ add(preferredShellRcFile());
202
+ }
203
+ return targets;
204
+ }
205
+
206
+ function ensureLineInFile(filePath, block) {
207
+ try {
208
+ const current = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
209
+ if (current.includes(block.trim())) return true;
210
+ const next = `${current.replace(/\s*$/, "")}\n\n${block}\n`;
211
+ mkdirSync(dirname(filePath), { recursive: true });
212
+ writeFileSync(filePath, next, "utf8");
213
+ return true;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ function shimScriptText(specifier = "latest") {
220
+ return [
221
+ "#!/usr/bin/env bash",
222
+ "set -euo pipefail",
223
+ 'export npm_config_cache="${npm_config_cache:-$HOME/.silicaclaw/npm-cache}"',
224
+ `exec npx -y @silicaclaw/cli@${specifier} "$@"`,
225
+ "",
226
+ ].join("\n");
227
+ }
228
+
229
+ function ensureUserCommandInstalled() {
230
+ const version = readPackageVersionFrom(APP_DIR);
231
+ const envBlock = [
232
+ "#!/usr/bin/env bash",
233
+ 'export PATH="$HOME/.silicaclaw/bin:$PATH"',
234
+ 'export npm_config_cache="$HOME/.silicaclaw/npm-cache"',
235
+ "",
236
+ ].join("\n");
237
+ const rcBlock = [
238
+ "# >>> silicaclaw >>>",
239
+ '[ -f "$HOME/.silicaclaw/env.sh" ] && . "$HOME/.silicaclaw/env.sh"',
240
+ "# <<< silicaclaw <<<",
241
+ ].join("\n");
242
+
243
+ mkdirSync(userShimDir(), { recursive: true });
244
+ mkdirSync(userNpmCacheDir(), { recursive: true });
245
+ writeFileSync(userEnvFile(), envBlock, { encoding: "utf8", mode: 0o755 });
246
+ writeFileSync(userShimPath(), shimScriptText(version), { encoding: "utf8", mode: 0o755 });
247
+
248
+ for (const filePath of shellInitTargets()) {
249
+ ensureLineInFile(filePath, rcBlock);
250
+ }
251
+ }
252
+
147
253
  function ensureLaunchAgentsDir() {
148
254
  mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
149
255
  }
@@ -797,6 +903,7 @@ async function stopAll() {
797
903
 
798
904
  async function startAll() {
799
905
  ensureStateDir();
906
+ ensureUserCommandInstalled();
800
907
 
801
908
  const mode = parseMode(parseFlag("mode", process.env.NETWORK_MODE || DEFAULT_NETWORK_MODE));
802
909
  const adapter = adapterForMode(mode);
@@ -923,6 +1030,7 @@ async function startAll() {
923
1030
  }
924
1031
 
925
1032
  async function restartAll() {
1033
+ ensureUserCommandInstalled();
926
1034
  if (!isLaunchdPlatform()) {
927
1035
  await stopAll();
928
1036
  await startAll();