@zokizuan/satori-cli 0.3.2 → 0.4.1

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 ham-zax
3
+ Copyright (c) 2026 Hamza (@ham-zax)
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,31 +1,64 @@
1
1
  # @zokizuan/satori-cli
2
2
 
3
- Shell CLI for installing Satori MCP config, copying first-party skills, checking setup, and calling Satori tools without a resident MCP client.
3
+ Installer and shell client for Satori MCP. Use this package to configure supported MCP clients, check provider setup, and call Satori tools from a terminal without starting a resident MCP client.
4
4
 
5
- ## Install Satori for a Client
5
+ ## Quick Start
6
6
 
7
7
  ```bash
8
- npx -y @zokizuan/satori-cli@0.3.2 install --client codex
9
- npx -y @zokizuan/satori-cli@0.3.2 install --client claude
10
- npx -y @zokizuan/satori-cli@0.3.2 install --client all --dry-run
11
- npx -y @zokizuan/satori-cli@0.3.2 uninstall --client codex
12
- npx -y @zokizuan/satori-cli@0.3.2 doctor
8
+ npx -y @zokizuan/satori-cli@0.4.1 install --client all
9
+ npx -y @zokizuan/satori-cli@0.4.1 doctor
13
10
  ```
14
11
 
15
- Managed installs write config that launches:
12
+ Supported clients are `codex`, `claude`, `opencode`, and `all`.
16
13
 
17
- ```toml
18
- [mcp_servers.satori]
19
- command = "npx"
20
- args = ["-y", "@zokizuan/satori-mcp@4.10.1"]
21
- startup_timeout_ms = 180000
14
+ The installer performs package resolution once, stores the MCP server under `~/.satori/mcp-runtime/`, writes a stable launcher at `~/.satori/bin/satori-mcp.js`, and writes client-specific config that starts the launcher directly with Node. Resident MCP startup should not run `npx` or require a custom long startup timeout.
15
+
16
+ Treat `~/.satori/` as installer-owned state. The public setup path is the installer command above, not manual copying of runtime cache paths into each harness.
17
+
18
+ The installer only manages Satori-owned config and the first-party workflow skill:
19
+
20
+ - `satori`
21
+
22
+ ## Commands
23
+
24
+ ```bash
25
+ npx -y @zokizuan/satori-cli@0.4.1 install --client codex
26
+ npx -y @zokizuan/satori-cli@0.4.1 install --client claude
27
+ npx -y @zokizuan/satori-cli@0.4.1 install --client opencode
28
+ npx -y @zokizuan/satori-cli@0.4.1 install --client all --dry-run
29
+ npx -y @zokizuan/satori-cli@0.4.1 uninstall --client codex
30
+ ```
31
+
32
+ `doctor` checks Node, package visibility, provider env, and Milvus env without starting an MCP client.
33
+
34
+ Typical first run:
35
+
36
+ ```bash
37
+ npx -y @zokizuan/satori-cli@0.4.1 install --client all
38
+ npx -y @zokizuan/satori-cli@0.4.1 doctor
39
+ # restart your MCP client
22
40
  ```
23
41
 
24
- The installer only manages Satori-owned config and skills:
42
+ The installer writes launcher config only. Runtime provider settings are read when the MCP client starts.
43
+
44
+ Supported client installs expose the Satori runtime variable names in the client config:
45
+
46
+ - Codex writes active `env_vars` forwarding plus an optional commented `[mcp_servers.satori.env]` template in `~/.codex/config.toml`.
47
+ - Claude Code writes `mcpServers.satori.env` in `~/.claude.json` with `${VAR:-}` pass-through values.
48
+ - OpenCode writes `mcp.satori.environment` in `~/.config/opencode/opencode.json` with `{env:VAR}` pass-through values.
25
49
 
26
- - `satori-search`
27
- - `satori-navigation`
28
- - `satori-indexing`
50
+ If you want a client to store literal values, replace the generated pass-through value for that client. In Codex, uncomment or add this table outside the installer-managed launcher block so reinstalls keep your edits:
51
+
52
+ ```toml
53
+ [mcp_servers.satori.env]
54
+ EMBEDDING_PROVIDER = "VoyageAI"
55
+ EMBEDDING_MODEL = "voyage-4-large"
56
+ EMBEDDING_OUTPUT_DIMENSION = "1024"
57
+ VOYAGEAI_API_KEY = "pa-..."
58
+ VOYAGEAI_RERANKER_MODEL = "rerank-2.5"
59
+ MILVUS_ADDRESS = "https://your-zilliz-endpoint"
60
+ MILVUS_TOKEN = "your-zilliz-token"
61
+ ```
29
62
 
30
63
  ## Direct Tool Calls
31
64
 
@@ -39,11 +72,23 @@ satori-cli search_codebase --path /abs/repo --query "auth flow"
39
72
 
40
73
  Global flags such as `--startup-timeout-ms`, `--call-timeout-ms`, `--format`, and `--debug` must appear before the command token.
41
74
 
42
- `doctor` checks Node, package visibility, provider env, and Milvus env without starting an MCP client.
75
+ ## Runtime Requirements
76
+
77
+ Indexing and search require an embedding provider plus a Milvus-compatible backend. MCP startup and `tools list` do not require those credentials; provider-backed tool calls return `MISSING_PROVIDER_CONFIG` when setup is incomplete.
78
+
79
+ Common local setup:
80
+
81
+ ```bash
82
+ EMBEDDING_PROVIDER=Ollama
83
+ EMBEDDING_MODEL=nomic-embed-text
84
+ OLLAMA_HOST=http://127.0.0.1:11434
85
+ MILVUS_ADDRESS=localhost:19530
86
+ ```
43
87
 
44
88
  ## Development
45
89
 
46
90
  ```bash
47
91
  pnpm --filter @zokizuan/satori-cli build
48
92
  pnpm --filter @zokizuan/satori-cli test
93
+ pnpm run release:smoke:cli
49
94
  ```
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: satori
3
+ description: Use when working with Satori MCP for code search, exact navigation, call graph context, bounded reads, indexing, sync, reindex, or stale index recovery.
4
+ ---
5
+
6
+ # Satori
7
+
8
+ Use this skill when a task needs Satori MCP for code discovery, navigation, or index lifecycle work.
9
+
10
+ ## Tools
11
+
12
+ Satori exposes exactly six MCP tools:
13
+
14
+ 1. `list_codebases`
15
+ 2. `manage_index`
16
+ 3. `search_codebase`
17
+ 4. `file_outline`
18
+ 5. `call_graph`
19
+ 6. `read_file`
20
+
21
+ ## Default Workflow
22
+
23
+ 1. Use `manage_index(action="status", path=...)` when index state is unknown.
24
+ 2. If the codebase is not indexed, use `manage_index(action="create", path=...)`.
25
+ 3. Search the requested path with `search_codebase(path=..., query=..., scope="runtime", resultMode="grouped", groupBy="symbol", rankingMode="auto_changed_first")`.
26
+ 4. Use `file_outline(resolveMode="exact", symbolIdExact|symbolLabelExact)` to lock exact symbol spans when identity is available.
27
+ 5. If `callGraphHint.supported=true`, call `call_graph(path=..., symbolRef=..., direction="both", depth=1)`.
28
+ 6. Use `read_file(path=..., open_symbol=...)` or deterministic line spans for final evidence before editing.
29
+
30
+ ## Search Rules
31
+
32
+ - Start with natural-language intent, not filenames.
33
+ - Default to `scope="runtime"`.
34
+ - Use operators only when useful: `lang:`, `path:`, `-path:`, `must:`, `exclude:`.
35
+ - Pass the user's requested path; if Satori resolves an indexed parent, follow returned fallback payloads exactly.
36
+ - Treat warnings as usable-but-degraded results, not fatal errors.
37
+ - Use `debug=true` only when ranking or filter explanations are required.
38
+
39
+ ## Navigation Rules
40
+
41
+ - Treat `navigationFallback` as authoritative. Do not invent spans.
42
+ - `open_symbol` must resolve deterministically. Do not guess on ambiguity.
43
+ - Prefer `read_file(mode="annotated")` when outline metadata helps.
44
+ - Follow continuation hints when plain reads are truncated.
45
+ - Read the relevant implementation and call sites before editing behavior.
46
+
47
+ ## Index Rules
48
+
49
+ - If any tool returns `requires_reindex`, run `manage_index(action="reindex")`, then retry the original call. Do not substitute `sync`.
50
+ - Use `manage_index(action="sync")` for freshness convergence and ignore-rule updates.
51
+ - Never call `manage_index(action="clear")` unless the user explicitly requests destructive reset.
52
+ - Respect blocked and actively indexing states instead of forcing retries blindly.
53
+ - `MISSING_PROVIDER_CONFIG` is active only when it appears as the tool response `code` or `reason`. If it appears inside search results, it may just be matched code content.
54
+
55
+ ## Status Handling
56
+
57
+ - `not_indexed`: create the index.
58
+ - `not_ready` with indexing reason: check status and wait for terminal completion.
59
+ - `requires_reindex`: reindex before trusting search or navigation.
60
+ - `unsupported`: fall back to deterministic `read_file` spans when supplied by `navigationFallback`.
61
+ - Noise mitigation hint: update `.satoriignore`, wait debounce, rerun search, and use `manage_index(action="sync")` only for immediate convergence.
package/dist/args.d.ts CHANGED
@@ -49,7 +49,7 @@ export interface ResolveRawArgsOptions {
49
49
  stdin?: NodeJS.ReadStream;
50
50
  stdinTimeoutMs: number;
51
51
  }
52
- export type InstallClient = "all" | "claude" | "codex";
52
+ export type InstallClient = "all" | "claude" | "codex" | "opencode";
53
53
  export declare function parseCliArgs(argv: string[]): ParsedCliInput;
54
54
  export declare function resolveRawArguments(rawArgsMode: RawArgsMode, options: ResolveRawArgsOptions): Promise<Record<string, unknown>>;
55
55
  export declare function parseWrapperArgumentsFromSchema(toolName: string, inputSchema: unknown, wrapperArgs: string[]): Record<string, unknown>;
package/dist/args.js CHANGED
@@ -20,7 +20,7 @@ function stripFlagPrefix(token) {
20
20
  }
21
21
  function parseGlobalOptions(argv) {
22
22
  const globals = {
23
- startupTimeoutMs: 180000,
23
+ startupTimeoutMs: 30000,
24
24
  callTimeoutMs: 600000,
25
25
  format: "json",
26
26
  debug: false,
@@ -116,8 +116,8 @@ function parseInstallCommand(kind, args) {
116
116
  const token = args[i];
117
117
  if (token === "--client") {
118
118
  const next = args[i + 1];
119
- if (next !== "all" && next !== "claude" && next !== "codex") {
120
- throw new CliError("E_USAGE", "--client must be one of: all, claude, codex.", 2);
119
+ if (next !== "all" && next !== "claude" && next !== "codex" && next !== "opencode") {
120
+ throw new CliError("E_USAGE", "--client must be one of: all, claude, codex, opencode.", 2);
121
121
  }
122
122
  client = next;
123
123
  i += 1;
package/dist/doctor.js CHANGED
@@ -7,6 +7,32 @@ function parseNodeMajor(version) {
7
7
  function selectedProvider(env) {
8
8
  return env.EMBEDDING_PROVIDER || "VoyageAI";
9
9
  }
10
+ function defaultModelForProvider(provider) {
11
+ switch (provider) {
12
+ case "OpenAI":
13
+ return "text-embedding-3-small";
14
+ case "VoyageAI":
15
+ return "voyage-4-large";
16
+ case "Gemini":
17
+ return "gemini-embedding-001";
18
+ case "Ollama":
19
+ return "nomic-embed-text";
20
+ default:
21
+ return "voyage-4-large";
22
+ }
23
+ }
24
+ function selectedModel(env, provider) {
25
+ if (provider === "Ollama") {
26
+ return env.OLLAMA_MODEL || env.EMBEDDING_MODEL || defaultModelForProvider(provider);
27
+ }
28
+ return env.EMBEDDING_MODEL || defaultModelForProvider(provider);
29
+ }
30
+ function selectedDimension(env, provider) {
31
+ if (env.EMBEDDING_OUTPUT_DIMENSION) {
32
+ return env.EMBEDDING_OUTPUT_DIMENSION;
33
+ }
34
+ return provider === "VoyageAI" ? "1024" : "provider default";
35
+ }
10
36
  function requiredEmbeddingEnv(provider) {
11
37
  switch (provider) {
12
38
  case "OpenAI":
@@ -62,17 +88,25 @@ export function runDoctor(options = {}) {
62
88
  nextSteps.push("Verify npm can access @zokizuan/satori-mcp from this machine.");
63
89
  }
64
90
  const provider = selectedProvider(env);
91
+ addCheck(checks, "embedding_provider", "ok", `Embedding provider: ${provider}.`);
92
+ addCheck(checks, "embedding_model", "ok", `Embedding model: ${selectedModel(env, provider)}.`);
93
+ addCheck(checks, "embedding_dimension", "ok", `Embedding output dimension: ${selectedDimension(env, provider)}.`);
65
94
  const requiredKey = requiredEmbeddingEnv(provider);
66
95
  if (requiredKey && !env[requiredKey]) {
67
96
  addCheck(checks, "embedding_provider_env", "error", `${provider} requires ${requiredKey}.`);
68
- nextSteps.push(`Set ${requiredKey}.`);
97
+ if (provider === "VoyageAI") {
98
+ nextSteps.push("Set VOYAGEAI_API_KEY from the Voyage AI dashboard API keys page.");
99
+ }
100
+ else {
101
+ nextSteps.push(`Set ${requiredKey}.`);
102
+ }
69
103
  }
70
104
  else {
71
105
  addCheck(checks, "embedding_provider_env", "ok", requiredKey ? `${requiredKey} is present.` : `${provider} does not require an API key.`);
72
106
  }
73
107
  if (!env.MILVUS_ADDRESS) {
74
108
  addCheck(checks, "milvus_address", "error", "MILVUS_ADDRESS is required for index/search/clear operations.");
75
- nextSteps.push("Set MILVUS_ADDRESS.");
109
+ nextSteps.push("Set MILVUS_ADDRESS to a Zilliz Cloud public endpoint or local Milvus address such as localhost:19530.");
76
110
  }
77
111
  else {
78
112
  addCheck(checks, "milvus_address", "ok", "MILVUS_ADDRESS is present.");
@@ -83,6 +117,9 @@ export function runDoctor(options = {}) {
83
117
  else {
84
118
  addCheck(checks, "milvus_token", "ok", "MILVUS_TOKEN is not set; local/unauthenticated Milvus endpoints are supported.");
85
119
  }
120
+ if (nextSteps.length > 0) {
121
+ nextSteps.push("Restart your MCP client after changing Satori environment variables.");
122
+ }
86
123
  return {
87
124
  status: overallStatus(checks),
88
125
  checks,
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { type ManagedRuntimeCommand } from "./install.js";
2
3
  import type { DoctorResult } from "./doctor.js";
3
4
  interface RunCliOptions {
4
5
  writeStdout?: (text: string) => void;
@@ -12,6 +13,7 @@ interface RunCliOptions {
12
13
  callTimeoutMs?: number;
13
14
  cwd?: string;
14
15
  installabilityVerifier?: () => string | Promise<string>;
16
+ installRuntimeCommand?: ManagedRuntimeCommand;
15
17
  doctorRunner?: (options: {
16
18
  env: NodeJS.ProcessEnv;
17
19
  }) => DoctorResult;
package/dist/index.js CHANGED
@@ -45,8 +45,8 @@ function buildHelpPayload() {
45
45
  return {
46
46
  usage: "satori-cli <command>",
47
47
  commands: [
48
- "install [--client all|codex|claude] [--dry-run]",
49
- "uninstall [--client all|codex|claude] [--dry-run]",
48
+ "install [--client all|codex|claude|opencode] [--dry-run]",
49
+ "uninstall [--client all|codex|claude|opencode] [--dry-run]",
50
50
  "doctor",
51
51
  "tools list",
52
52
  "tool call <toolName> --args-json '<json>'",
@@ -211,11 +211,14 @@ export async function runCli(argv, options = {}) {
211
211
  return result.status === "error" ? 1 : 0;
212
212
  }
213
213
  if (parsed.command.kind === "install" || parsed.command.kind === "uninstall") {
214
+ let packageSpecifier;
214
215
  if (parsed.command.kind === "install") {
215
- await (options.installabilityVerifier || verifyManagedPackageInstallability)();
216
+ packageSpecifier = await (options.installabilityVerifier || verifyManagedPackageInstallability)();
216
217
  }
217
218
  const result = executeInstallCommand(parsed.command, {
218
219
  homeDir: effectiveEnv.HOME,
220
+ packageSpecifier,
221
+ runtimeCommand: options.installRuntimeCommand,
219
222
  });
220
223
  emitJson(writers, result);
221
224
  if (parsed.globals.format === "text") {
package/dist/install.d.ts CHANGED
@@ -1,5 +1,11 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import type { InstallClient } from "./args.js";
3
+ type ExecFileSyncLike = typeof execFileSync;
2
4
  type ClientName = Exclude<InstallClient, "all">;
5
+ export interface ManagedRuntimeCommand {
6
+ command: string;
7
+ args: string[];
8
+ }
3
9
  export interface InstallCommandInput {
4
10
  kind: "install" | "uninstall";
5
11
  client: InstallClient;
@@ -9,13 +15,17 @@ export interface InstallCommandOptions {
9
15
  homeDir?: string;
10
16
  packageSpecifier?: string;
11
17
  skillAssetRoot?: string;
18
+ runtimeCommand?: ManagedRuntimeCommand;
19
+ execFileSyncImpl?: ExecFileSyncLike;
12
20
  }
13
21
  export interface ClientInstallResult {
14
22
  client: ClientName;
15
23
  configPath: string;
16
- skillsPath: string;
24
+ skillsPath?: string;
25
+ instructionsPath?: string;
17
26
  configChanged: boolean;
18
27
  skillsChanged: boolean;
28
+ instructionsChanged: boolean;
19
29
  status: "updated" | "unchanged";
20
30
  dryRun: boolean;
21
31
  }
package/dist/install.js CHANGED
@@ -1,13 +1,71 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
4
5
  import { fileURLToPath } from "node:url";
6
+ import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser";
5
7
  import { CliError } from "./errors.js";
6
8
  import { resolveManagedPackageSpecifier } from "./managed-package.js";
7
9
  const MANAGED_BLOCK_START = "# >>> satori-cli managed satori start >>>";
8
10
  const MANAGED_BLOCK_END = "# <<< satori-cli managed satori end <<<";
9
- const OWNED_SKILL_DIRS = ["satori-search", "satori-navigation", "satori-indexing"];
10
- const MANAGED_TIMEOUT_MS = 180000;
11
+ const CODEX_ENV_TEMPLATE_START = "# >>> satori-cli optional satori env template >>>";
12
+ const CODEX_ENV_TEMPLATE_END = "# <<< satori-cli optional satori env template <<<";
13
+ const INSTRUCTIONS_BLOCK_START = "<!-- satori-mcp:start -->";
14
+ const INSTRUCTIONS_BLOCK_END = "<!-- satori-mcp:end -->";
15
+ const OWNED_SKILL_DIRS = ["satori"];
16
+ const MANAGED_RUNTIME_DIR = "mcp-runtime";
17
+ const MANAGED_BIN_DIR = "bin";
18
+ const MANAGED_LAUNCHER_FILE = "satori-mcp.js";
19
+ const SATORI_RUNTIME_ENV_VARS = [
20
+ "EMBEDDING_PROVIDER",
21
+ "EMBEDDING_MODEL",
22
+ "EMBEDDING_OUTPUT_DIMENSION",
23
+ "OPENAI_API_KEY",
24
+ "OPENAI_BASE_URL",
25
+ "VOYAGEAI_API_KEY",
26
+ "VOYAGEAI_RERANKER_MODEL",
27
+ "GEMINI_API_KEY",
28
+ "GEMINI_BASE_URL",
29
+ "OLLAMA_HOST",
30
+ "OLLAMA_MODEL",
31
+ "MILVUS_ADDRESS",
32
+ "MILVUS_TOKEN",
33
+ "READ_FILE_MAX_LINES",
34
+ "MCP_ENABLE_WATCHER",
35
+ "MCP_WATCH_DEBOUNCE_MS",
36
+ ];
37
+ const CODEX_ENV_TEMPLATE_LINES = [
38
+ CODEX_ENV_TEMPLATE_START,
39
+ "# Optional direct Codex env values. Uncomment/fill these if you prefer",
40
+ "# ~/.codex/config.toml to store Satori runtime settings directly.",
41
+ "# This template is outside the launcher block so reinstall keeps edits.",
42
+ "# [mcp_servers.satori.env]",
43
+ "# EMBEDDING_PROVIDER = \"VoyageAI\"",
44
+ "# EMBEDDING_MODEL = \"voyage-4-large\"",
45
+ "# EMBEDDING_OUTPUT_DIMENSION = \"1024\"",
46
+ "# VOYAGEAI_API_KEY = \"pa-...\"",
47
+ "# VOYAGEAI_RERANKER_MODEL = \"rerank-2.5\"",
48
+ "# MILVUS_ADDRESS = \"https://your-zilliz-endpoint\"",
49
+ "# MILVUS_TOKEN = \"your-zilliz-token\"",
50
+ CODEX_ENV_TEMPLATE_END,
51
+ ];
52
+ const OPENCODE_INSTRUCTIONS = `# Satori MCP
53
+
54
+ This project uses Satori MCP for semantic code search, deterministic navigation, and index lifecycle management.
55
+
56
+ ## Priority Order
57
+ 1. \`search_codebase\` - find candidate code by behavior, concept, or symbol
58
+ 2. \`file_outline\` - lock exact symbol spans before reading or editing
59
+ 3. \`call_graph\` - inspect callers and callees when supported
60
+ 4. \`read_file\` - open exact spans or fallback windows
61
+ 5. \`manage_index\` - create, sync, reindex, or inspect index status
62
+
63
+ ## Rules
64
+ - Prefer Satori tools before grep/glob for code discovery.
65
+ - If a tool returns \`requires_reindex\`, run \`manage_index(action="reindex")\` and retry the original call.
66
+ - Treat \`navigationFallback\` as authoritative when call graph is unavailable.
67
+ - Read the relevant implementation and call sites before editing behavior.
68
+ `;
11
69
  function resolveDefaultSkillAssetRoot() {
12
70
  const currentFile = fileURLToPath(import.meta.url);
13
71
  return path.resolve(path.dirname(currentFile), "..", "assets", "skills");
@@ -26,12 +84,26 @@ function resolveClientTargets(homeDir) {
26
84
  {
27
85
  client: "codex",
28
86
  configPath: path.join(homeDir, ".codex", "config.toml"),
29
- skillsPath: path.join(homeDir, ".codex", "skills"),
87
+ companion: {
88
+ kind: "skills",
89
+ path: path.join(homeDir, ".codex", "skills"),
90
+ },
30
91
  },
31
92
  {
32
93
  client: "claude",
33
- configPath: path.join(homeDir, ".claude", "settings.json"),
34
- skillsPath: path.join(homeDir, ".claude", "skills"),
94
+ configPath: path.join(homeDir, ".claude.json"),
95
+ companion: {
96
+ kind: "skills",
97
+ path: path.join(homeDir, ".claude", "skills"),
98
+ },
99
+ },
100
+ {
101
+ client: "opencode",
102
+ configPath: path.join(homeDir, ".config", "opencode", "opencode.json"),
103
+ companion: {
104
+ kind: "instructions",
105
+ path: path.join(homeDir, ".config", "opencode", "AGENTS.md"),
106
+ },
35
107
  },
36
108
  ];
37
109
  }
@@ -60,29 +132,213 @@ function normalizeTrailingNewline(value) {
60
132
  function escapeRegExp(value) {
61
133
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
62
134
  }
63
- function buildCodexManagedBlock(packageSpecifier) {
135
+ function toTomlString(value) {
136
+ return JSON.stringify(value);
137
+ }
138
+ function buildTomlArray(values) {
139
+ return `[${values.map(toTomlString).join(", ")}]`;
140
+ }
141
+ function runtimeEnvMap(valueForName) {
142
+ return Object.fromEntries(SATORI_RUNTIME_ENV_VARS.map((name) => [name, valueForName(name)]));
143
+ }
144
+ function objectValue(value) {
145
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
146
+ return undefined;
147
+ }
148
+ return value;
149
+ }
150
+ function mergeRuntimeEnv(existing, defaults) {
151
+ return {
152
+ ...defaults,
153
+ ...(objectValue(existing) ?? {}),
154
+ };
155
+ }
156
+ function packageNameFromSpecifier(packageSpecifier) {
157
+ if (packageSpecifier.startsWith("@")) {
158
+ const versionMarker = packageSpecifier.indexOf("@", 1);
159
+ return versionMarker === -1 ? packageSpecifier : packageSpecifier.slice(0, versionMarker);
160
+ }
161
+ const versionMarker = packageSpecifier.indexOf("@");
162
+ return versionMarker === -1 ? packageSpecifier : packageSpecifier.slice(0, versionMarker);
163
+ }
164
+ function safeRuntimeDirName(packageSpecifier) {
165
+ return packageSpecifier.replace(/[^A-Za-z0-9._@-]+/g, "-");
166
+ }
167
+ function resolveRuntimeRoot(homeDir, packageSpecifier) {
168
+ return path.join(homeDir, ".satori", MANAGED_RUNTIME_DIR, safeRuntimeDirName(packageSpecifier));
169
+ }
170
+ function resolveRuntimePackageRoot(homeDir, packageSpecifier) {
171
+ return path.join(resolveRuntimeRoot(homeDir, packageSpecifier), "node_modules", ...packageNameFromSpecifier(packageSpecifier).split("/"));
172
+ }
173
+ function resolveRuntimeEntryPath(packageRoot, packageJson) {
174
+ const bin = packageJson?.bin;
175
+ let relativeEntry = "dist/index.js";
176
+ if (bin && typeof bin === "object" && !Array.isArray(bin) && typeof bin.satori === "string") {
177
+ relativeEntry = bin.satori;
178
+ }
179
+ else if (typeof bin === "string") {
180
+ relativeEntry = bin;
181
+ }
182
+ else if (typeof packageJson?.main === "string") {
183
+ relativeEntry = packageJson.main;
184
+ }
185
+ return path.resolve(packageRoot, relativeEntry);
186
+ }
187
+ function resolveLauncherPath(homeDir) {
188
+ return path.join(homeDir, ".satori", MANAGED_BIN_DIR, MANAGED_LAUNCHER_FILE);
189
+ }
190
+ function plannedManagedRuntimeCommand(homeDir, packageSpecifier) {
191
+ return {
192
+ command: process.execPath,
193
+ args: [resolveRuntimeEntryPath(resolveRuntimePackageRoot(homeDir, packageSpecifier))],
194
+ };
195
+ }
196
+ function managedClientCommand(homeDir) {
197
+ return {
198
+ command: process.execPath,
199
+ args: [resolveLauncherPath(homeDir)],
200
+ };
201
+ }
202
+ function buildLauncherScript(runtimeCommand) {
203
+ return [
204
+ "#!/usr/bin/env node",
205
+ "",
206
+ "const { spawn } = require(\"node:child_process\");",
207
+ "",
208
+ `const command = ${JSON.stringify(runtimeCommand.command)};`,
209
+ `const baseArgs = ${JSON.stringify(runtimeCommand.args)};`,
210
+ "const child = spawn(command, [...baseArgs, ...process.argv.slice(2)], {",
211
+ " stdio: \"inherit\",",
212
+ " env: process.env,",
213
+ "});",
214
+ "",
215
+ "child.on(\"error\", (error) => {",
216
+ " console.error(`Failed to start Satori MCP runtime: ${error.message}`);",
217
+ " process.exit(1);",
218
+ "});",
219
+ "",
220
+ "child.on(\"exit\", (code, signal) => {",
221
+ " if (signal) {",
222
+ " console.error(`Satori MCP runtime exited from signal ${signal}`);",
223
+ " process.exit(1);",
224
+ " }",
225
+ " process.exit(code ?? 0);",
226
+ "});",
227
+ "",
228
+ ].join("\n");
229
+ }
230
+ function writeTextFileAtomic(filePath, content, mode) {
231
+ ensureParentDir(filePath);
232
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
233
+ fs.writeFileSync(tempPath, content, "utf8");
234
+ if (mode !== undefined) {
235
+ fs.chmodSync(tempPath, mode);
236
+ }
237
+ fs.renameSync(tempPath, filePath);
238
+ }
239
+ function prepareLauncherInstall(homeDir, runtimeCommand) {
240
+ const launcherPath = resolveLauncherPath(homeDir);
241
+ const current = readTextIfExists(launcherPath);
242
+ const next = buildLauncherScript(runtimeCommand);
243
+ return {
244
+ changed: current !== next,
245
+ apply: () => {
246
+ if (current === next) {
247
+ return;
248
+ }
249
+ writeTextFileAtomic(launcherPath, next, 0o755);
250
+ },
251
+ };
252
+ }
253
+ function npmOutput(error) {
254
+ if (!(error instanceof Error)) {
255
+ return String(error);
256
+ }
257
+ const stdout = "stdout" in error && typeof error.stdout === "string"
258
+ ? error.stdout
259
+ : "";
260
+ const stderr = "stderr" in error && typeof error.stderr === "string"
261
+ ? error.stderr
262
+ : "";
263
+ return `${stdout}\n${stderr}\n${error.message}`.trim();
264
+ }
265
+ function installManagedRuntimeCommand(homeDir, packageSpecifier, execImpl) {
266
+ const runtimeRoot = resolveRuntimeRoot(homeDir, packageSpecifier);
267
+ ensureDir(runtimeRoot);
268
+ try {
269
+ execImpl("npm", [
270
+ "install",
271
+ "--prefix",
272
+ runtimeRoot,
273
+ "--omit=dev",
274
+ "--no-package-lock",
275
+ "--ignore-scripts",
276
+ "--no-audit",
277
+ "--no-fund",
278
+ packageSpecifier,
279
+ ], {
280
+ encoding: "utf8",
281
+ stdio: ["ignore", "pipe", "pipe"],
282
+ });
283
+ }
284
+ catch (error) {
285
+ throw new CliError("E_USAGE", `Failed to install Satori MCP runtime package ${packageSpecifier} into ${runtimeRoot}. ${npmOutput(error)}`, 2);
286
+ }
287
+ const packageRoot = resolveRuntimePackageRoot(homeDir, packageSpecifier);
288
+ const packageJsonPath = path.join(packageRoot, "package.json");
289
+ let packageJson;
290
+ try {
291
+ packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
292
+ }
293
+ catch (error) {
294
+ throw new CliError("E_USAGE", `Installed Satori MCP runtime is missing package metadata at ${packageJsonPath}: ${error.message}`, 2);
295
+ }
296
+ const command = {
297
+ command: process.execPath,
298
+ args: [resolveRuntimeEntryPath(packageRoot, packageJson)],
299
+ };
300
+ if (!fs.existsSync(command.args[0])) {
301
+ throw new CliError("E_USAGE", `Installed Satori MCP runtime entry does not exist: ${command.args[0]}`, 2);
302
+ }
303
+ return command;
304
+ }
305
+ function buildCodexManagedBlock(runtimeCommand) {
64
306
  return [
65
307
  MANAGED_BLOCK_START,
66
308
  "[mcp_servers.satori]",
67
- 'command = "npx"',
68
- `args = ["-y", "${packageSpecifier}"]`,
69
- `startup_timeout_ms = ${MANAGED_TIMEOUT_MS}`,
309
+ `command = ${toTomlString(runtimeCommand.command)}`,
310
+ `args = ${buildTomlArray(runtimeCommand.args)}`,
311
+ "# Satori reads provider/vector settings from environment at MCP startup.",
312
+ "# env_vars forwards these names from Codex's parent environment when set.",
313
+ `env_vars = ${buildTomlArray([...SATORI_RUNTIME_ENV_VARS])}`,
70
314
  MANAGED_BLOCK_END,
71
315
  "",
72
316
  ].join("\n");
73
317
  }
318
+ function buildCodexEnvTemplateBlock() {
319
+ return `${CODEX_ENV_TEMPLATE_LINES.join("\n")}\n`;
320
+ }
321
+ function codexHasSatoriEnvTable(content) {
322
+ return /^\s*\[mcp_servers\.satori\.env\]\s*$/m.test(content);
323
+ }
324
+ function ensureCodexEnvTemplate(content) {
325
+ if (content.includes(CODEX_ENV_TEMPLATE_START) || codexHasSatoriEnvTable(content)) {
326
+ return content;
327
+ }
328
+ return `${normalizeTrailingNewline(content)}\n${buildCodexEnvTemplateBlock()}`;
329
+ }
74
330
  function codexHasUnmanagedSatoriSection(content) {
75
331
  if (!content.includes("[mcp_servers.satori]")) {
76
332
  return false;
77
333
  }
78
334
  return !(content.includes(MANAGED_BLOCK_START) && content.includes(MANAGED_BLOCK_END));
79
335
  }
80
- function prepareCodexInstall(filePath, packageSpecifier) {
336
+ function prepareCodexInstall(filePath, runtimeCommand) {
81
337
  const current = readTextIfExists(filePath) ?? "";
82
338
  if (codexHasUnmanagedSatoriSection(current)) {
83
339
  throw new CliError("E_USAGE", `Refusing to overwrite unmanaged Satori config in ${filePath}. Remove [mcp_servers.satori] manually or convert it to the managed block first.`, 2);
84
340
  }
85
- const managedBlock = buildCodexManagedBlock(packageSpecifier);
341
+ const managedBlock = buildCodexManagedBlock(runtimeCommand);
86
342
  let next = current;
87
343
  if (current.includes(MANAGED_BLOCK_START) && current.includes(MANAGED_BLOCK_END)) {
88
344
  next = current.replace(new RegExp(`${escapeRegExp(MANAGED_BLOCK_START)}[\\s\\S]*?${escapeRegExp(MANAGED_BLOCK_END)}\\n?`, "m"), managedBlock);
@@ -93,6 +349,7 @@ function prepareCodexInstall(filePath, packageSpecifier) {
93
349
  else {
94
350
  next = `${normalizeTrailingNewline(current)}\n${managedBlock}`;
95
351
  }
352
+ next = ensureCodexEnvTemplate(next);
96
353
  return {
97
354
  changed: next !== current,
98
355
  apply: () => {
@@ -146,45 +403,39 @@ function parseJsonObject(filePath) {
146
403
  }
147
404
  return parsed;
148
405
  }
149
- function buildClaudeServerConfig(packageSpecifier) {
406
+ function buildClaudeServerConfig(runtimeCommand, existing) {
150
407
  return {
151
- command: "npx",
152
- args: ["-y", packageSpecifier],
153
- timeout: MANAGED_TIMEOUT_MS,
408
+ type: "stdio",
409
+ command: runtimeCommand.command,
410
+ args: runtimeCommand.args,
411
+ env: mergeRuntimeEnv(existing?.env, runtimeEnvMap((name) => `\${${name}:-}`)),
154
412
  };
155
413
  }
156
- function isManagedPackageSpecifier(value) {
157
- return typeof value === "string" && /^@zokizuan\/satori-mcp@.+$/.test(value);
414
+ function isManagedLauncherPath(value) {
415
+ return typeof value === "string" && value.replace(/\\/g, "/").endsWith(`/.satori/${MANAGED_BIN_DIR}/${MANAGED_LAUNCHER_FILE}`);
416
+ }
417
+ function isManagedCommandParts(command, args) {
418
+ if (!Array.isArray(args)) {
419
+ return false;
420
+ }
421
+ const entryPath = args[0];
422
+ return typeof command === "string"
423
+ && command.length > 0
424
+ && args.length === 1
425
+ && isManagedLauncherPath(entryPath);
158
426
  }
159
427
  function isManagedClaudeEntry(value) {
160
428
  if (!value || typeof value !== "object" || Array.isArray(value)) {
161
429
  return false;
162
430
  }
163
431
  const entry = value;
164
- if (entry.command !== "npx") {
165
- return false;
166
- }
167
- if (entry.timeout !== MANAGED_TIMEOUT_MS) {
168
- return false;
169
- }
170
- if (!Array.isArray(entry.args)) {
171
- return false;
172
- }
173
- if (entry.args.length === 2) {
174
- return entry.args[0] === "-y" && isManagedPackageSpecifier(entry.args[1]);
175
- }
176
- if (entry.args.length === 4) {
177
- return entry.args[0] === "-y"
178
- && entry.args[1] === "--package"
179
- && isManagedPackageSpecifier(entry.args[2])
180
- && entry.args[3] === "satori";
181
- }
182
- return false;
432
+ return isManagedCommandParts(entry.command, entry.args);
183
433
  }
184
- function prepareClaudeInstall(filePath, packageSpecifier) {
434
+ function prepareClaudeInstall(filePath, runtimeCommand) {
185
435
  const currentObject = parseJsonObject(filePath);
186
436
  const currentSerialized = JSON.stringify(currentObject);
187
- const desiredServer = buildClaudeServerConfig(packageSpecifier);
437
+ const existingSatori = objectValue(currentObject.mcpServers?.satori);
438
+ const desiredServer = buildClaudeServerConfig(runtimeCommand, existingSatori);
188
439
  const mcpServersValue = currentObject.mcpServers;
189
440
  let mcpServers;
190
441
  if (mcpServersValue === undefined) {
@@ -196,14 +447,14 @@ function prepareClaudeInstall(filePath, packageSpecifier) {
196
447
  else {
197
448
  throw new CliError("E_USAGE", `Expected mcpServers to be an object in ${filePath}.`, 2);
198
449
  }
199
- const existingSatori = mcpServers.satori;
200
- if (existingSatori !== undefined && !isManagedClaudeEntry(existingSatori)) {
201
- throw new CliError("E_USAGE", `Refusing to overwrite unmanaged Satori config in ${filePath}. Remove mcpServers.satori manually or align it to the managed npx form first.`, 2);
450
+ if (mcpServers.satori !== undefined && !isManagedClaudeEntry(mcpServers.satori)) {
451
+ throw new CliError("E_USAGE", `Refusing to overwrite unmanaged Satori config in ${filePath}. Remove mcpServers.satori manually or align it to the managed Satori form first.`, 2);
202
452
  }
203
453
  mcpServers.satori = {
204
454
  ...existingSatori,
205
455
  ...desiredServer,
206
456
  };
457
+ delete mcpServers.satori.timeout;
207
458
  currentObject.mcpServers = mcpServers;
208
459
  const next = `${JSON.stringify(currentObject, null, 2)}\n`;
209
460
  return {
@@ -245,6 +496,88 @@ function prepareClaudeUninstall(filePath) {
245
496
  },
246
497
  };
247
498
  }
499
+ function parseJsoncObject(filePath, content) {
500
+ const errors = [];
501
+ const parsed = parseJsonc(content, errors, { allowTrailingComma: true, disallowComments: false });
502
+ if (errors.length > 0) {
503
+ throw new CliError("E_USAGE", `Failed to parse JSONC config at ${filePath}.`, 2);
504
+ }
505
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
506
+ throw new CliError("E_USAGE", `Expected top-level JSON object in ${filePath}.`, 2);
507
+ }
508
+ return parsed;
509
+ }
510
+ function buildOpenCodeServerConfig(runtimeCommand, existing) {
511
+ return {
512
+ enabled: true,
513
+ type: "local",
514
+ command: [runtimeCommand.command, ...runtimeCommand.args],
515
+ environment: mergeRuntimeEnv(existing?.environment, runtimeEnvMap((name) => `{env:${name}}`)),
516
+ };
517
+ }
518
+ function isManagedOpenCodeEntry(value) {
519
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
520
+ return false;
521
+ }
522
+ const entry = value;
523
+ if (Array.isArray(entry.command)) {
524
+ const [command, ...args] = entry.command;
525
+ return isManagedCommandParts(command, args);
526
+ }
527
+ return isManagedCommandParts(entry.command, entry.args);
528
+ }
529
+ function mutateJsonc(filePath, current, pathSegments, value) {
530
+ const edits = modify(current, pathSegments, value, {
531
+ formattingOptions: {
532
+ insertSpaces: true,
533
+ tabSize: 2,
534
+ eol: "\n",
535
+ },
536
+ });
537
+ const next = applyEdits(current, edits);
538
+ return {
539
+ changed: next !== current,
540
+ apply: () => {
541
+ if (next === current) {
542
+ return;
543
+ }
544
+ ensureParentDir(filePath);
545
+ fs.writeFileSync(filePath, next.endsWith("\n") ? next : `${next}\n`, "utf8");
546
+ },
547
+ };
548
+ }
549
+ function prepareOpenCodeInstall(filePath, runtimeCommand) {
550
+ const current = readTextIfExists(filePath) ?? "{}\n";
551
+ const currentObject = parseJsoncObject(filePath, current);
552
+ const mcpValue = currentObject.mcp;
553
+ if (mcpValue !== undefined && (!mcpValue || typeof mcpValue !== "object" || Array.isArray(mcpValue))) {
554
+ throw new CliError("E_USAGE", `Expected mcp to be an object in ${filePath}.`, 2);
555
+ }
556
+ const existingSatori = mcpValue?.satori;
557
+ if (existingSatori !== undefined && !isManagedOpenCodeEntry(existingSatori)) {
558
+ throw new CliError("E_USAGE", `Refusing to overwrite unmanaged Satori config in ${filePath}. Remove mcp.satori manually or align it to the managed Satori form first.`, 2);
559
+ }
560
+ return mutateJsonc(filePath, current, ["mcp", "satori"], buildOpenCodeServerConfig(runtimeCommand, objectValue(existingSatori)));
561
+ }
562
+ function prepareOpenCodeUninstall(filePath) {
563
+ const current = readTextIfExists(filePath);
564
+ if (!current) {
565
+ return { changed: false, apply: () => { } };
566
+ }
567
+ const currentObject = parseJsoncObject(filePath, current);
568
+ const mcpValue = currentObject.mcp;
569
+ if (!mcpValue || typeof mcpValue !== "object" || Array.isArray(mcpValue)) {
570
+ return { changed: false, apply: () => { } };
571
+ }
572
+ const existingSatori = mcpValue.satori;
573
+ if (existingSatori === undefined) {
574
+ return { changed: false, apply: () => { } };
575
+ }
576
+ if (!isManagedOpenCodeEntry(existingSatori)) {
577
+ throw new CliError("E_USAGE", `Refusing to remove unmanaged Satori config in ${filePath}. Remove mcp.satori manually instead.`, 2);
578
+ }
579
+ return mutateJsonc(filePath, current, ["mcp", "satori"], undefined);
580
+ }
248
581
  function prepareSkillInstall(skillsPath, skillAssetRoot) {
249
582
  const writes = [];
250
583
  let changed = false;
@@ -284,24 +617,92 @@ function prepareSkillRemoval(skillsPath) {
284
617
  },
285
618
  };
286
619
  }
287
- function prepareMutation(target, command, packageSpecifier, skillAssetRoot) {
288
- const configMutation = command.kind === "install"
289
- ? target.client === "codex"
290
- ? prepareCodexInstall(target.configPath, packageSpecifier)
291
- : prepareClaudeInstall(target.configPath, packageSpecifier)
292
- : target.client === "codex"
293
- ? prepareCodexUninstall(target.configPath)
620
+ function buildManagedInstructionsBlock() {
621
+ return [
622
+ INSTRUCTIONS_BLOCK_START,
623
+ OPENCODE_INSTRUCTIONS.trim(),
624
+ INSTRUCTIONS_BLOCK_END,
625
+ "",
626
+ ].join("\n");
627
+ }
628
+ function prepareInstructionsInstall(filePath) {
629
+ const current = readTextIfExists(filePath) ?? "";
630
+ const block = buildManagedInstructionsBlock();
631
+ let next = current;
632
+ if (current.includes(INSTRUCTIONS_BLOCK_START) && current.includes(INSTRUCTIONS_BLOCK_END)) {
633
+ next = current.replace(new RegExp(`${escapeRegExp(INSTRUCTIONS_BLOCK_START)}[\\s\\S]*?${escapeRegExp(INSTRUCTIONS_BLOCK_END)}\\n?`, "m"), block);
634
+ }
635
+ else if (current.trim().length === 0) {
636
+ next = block;
637
+ }
638
+ else {
639
+ next = `${normalizeTrailingNewline(current)}\n${block}`;
640
+ }
641
+ return {
642
+ changed: next !== current,
643
+ apply: () => {
644
+ if (next === current) {
645
+ return;
646
+ }
647
+ ensureParentDir(filePath);
648
+ fs.writeFileSync(filePath, next, "utf8");
649
+ },
650
+ };
651
+ }
652
+ function prepareInstructionsRemoval(filePath) {
653
+ const current = readTextIfExists(filePath);
654
+ if (!current || !current.includes(INSTRUCTIONS_BLOCK_START) || !current.includes(INSTRUCTIONS_BLOCK_END)) {
655
+ return { changed: false, apply: () => { } };
656
+ }
657
+ const next = current
658
+ .replace(new RegExp(`\\n?${escapeRegExp(INSTRUCTIONS_BLOCK_START)}[\\s\\S]*?${escapeRegExp(INSTRUCTIONS_BLOCK_END)}\\n?`, "m"), "\n")
659
+ .replace(/\n{3,}/g, "\n\n")
660
+ .replace(/^\n+/, "");
661
+ return {
662
+ changed: next !== current,
663
+ apply: () => {
664
+ if (next === current) {
665
+ return;
666
+ }
667
+ fs.writeFileSync(filePath, next, "utf8");
668
+ },
669
+ };
670
+ }
671
+ function prepareCompanionMutation(companion, command, skillAssetRoot) {
672
+ if (companion.kind === "skills") {
673
+ return command.kind === "install"
674
+ ? prepareSkillInstall(companion.path, skillAssetRoot)
675
+ : prepareSkillRemoval(companion.path);
676
+ }
677
+ return command.kind === "install"
678
+ ? prepareInstructionsInstall(companion.path)
679
+ : prepareInstructionsRemoval(companion.path);
680
+ }
681
+ function prepareConfigMutation(target, command, runtimeCommand) {
682
+ if (target.client === "codex") {
683
+ return command.kind === "install"
684
+ ? prepareCodexInstall(target.configPath, runtimeCommand)
685
+ : prepareCodexUninstall(target.configPath);
686
+ }
687
+ if (target.client === "claude") {
688
+ return command.kind === "install"
689
+ ? prepareClaudeInstall(target.configPath, runtimeCommand)
294
690
  : prepareClaudeUninstall(target.configPath);
295
- const skillsMutation = command.kind === "install"
296
- ? prepareSkillInstall(target.skillsPath, skillAssetRoot)
297
- : prepareSkillRemoval(target.skillsPath);
691
+ }
692
+ return command.kind === "install"
693
+ ? prepareOpenCodeInstall(target.configPath, runtimeCommand)
694
+ : prepareOpenCodeUninstall(target.configPath);
695
+ }
696
+ function prepareMutation(target, command, runtimeCommand, skillAssetRoot) {
697
+ const configMutation = prepareConfigMutation(target, command, runtimeCommand);
698
+ const companionMutation = prepareCompanionMutation(target.companion, command, skillAssetRoot);
298
699
  return {
299
700
  target,
300
701
  configChanged: configMutation.changed,
301
- skillsChanged: skillsMutation.changed,
702
+ companionChanged: companionMutation.changed,
302
703
  apply: () => {
303
704
  configMutation.apply();
304
- skillsMutation.apply();
705
+ companionMutation.apply();
305
706
  },
306
707
  };
307
708
  }
@@ -309,8 +710,20 @@ export function executeInstallCommand(command, options = {}) {
309
710
  const homeDir = options.homeDir ?? os.homedir();
310
711
  const packageSpecifier = options.packageSpecifier ?? resolveDefaultPackageSpecifier();
311
712
  const skillAssetRoot = options.skillAssetRoot ?? resolveDefaultSkillAssetRoot();
312
- const prepared = selectTargets(homeDir, command.client).map((target) => (prepareMutation(target, command, packageSpecifier, skillAssetRoot)));
713
+ const plannedRuntimeCommand = options.runtimeCommand ?? plannedManagedRuntimeCommand(homeDir, packageSpecifier);
714
+ const clientCommand = managedClientCommand(homeDir);
715
+ let launcherMutation = command.kind === "install"
716
+ ? prepareLauncherInstall(homeDir, plannedRuntimeCommand)
717
+ : { changed: false, apply: () => { } };
718
+ const prepared = selectTargets(homeDir, command.client).map((target) => (prepareMutation(target, command, clientCommand, skillAssetRoot)));
313
719
  if (!command.dryRun) {
720
+ if (command.kind === "install" && !options.runtimeCommand) {
721
+ const installedRuntimeCommand = installManagedRuntimeCommand(homeDir, packageSpecifier, options.execFileSyncImpl ?? execFileSync);
722
+ launcherMutation = prepareLauncherInstall(homeDir, installedRuntimeCommand);
723
+ }
724
+ if (command.kind === "install") {
725
+ launcherMutation.apply();
726
+ }
314
727
  for (const mutation of prepared) {
315
728
  mutation.apply();
316
729
  }
@@ -322,10 +735,12 @@ export function executeInstallCommand(command, options = {}) {
322
735
  results: prepared.map((mutation) => ({
323
736
  client: mutation.target.client,
324
737
  configPath: mutation.target.configPath,
325
- skillsPath: mutation.target.skillsPath,
738
+ skillsPath: mutation.target.companion.kind === "skills" ? mutation.target.companion.path : undefined,
739
+ instructionsPath: mutation.target.companion.kind === "instructions" ? mutation.target.companion.path : undefined,
326
740
  configChanged: mutation.configChanged,
327
- skillsChanged: mutation.skillsChanged,
328
- status: mutation.configChanged || mutation.skillsChanged ? "updated" : "unchanged",
741
+ skillsChanged: mutation.target.companion.kind === "skills" ? mutation.companionChanged : false,
742
+ instructionsChanged: mutation.target.companion.kind === "instructions" ? mutation.companionChanged : false,
743
+ status: mutation.configChanged || mutation.companionChanged || launcherMutation.changed ? "updated" : "unchanged",
329
744
  dryRun: command.dryRun,
330
745
  })),
331
746
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zokizuan/satori-cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "Shell CLI for Satori MCP installation and skill-based workflows",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,12 +9,13 @@
9
9
  "satori-cli": "dist/index.js"
10
10
  },
11
11
  "dependencies": {
12
- "@modelcontextprotocol/sdk": "^1.12.1",
13
- "@zokizuan/satori-mcp": "4.10.1"
12
+ "@modelcontextprotocol/sdk": "^1.29.0",
13
+ "jsonc-parser": "^3.3.1",
14
+ "@zokizuan/satori-mcp": "4.11.1"
14
15
  },
15
16
  "devDependencies": {
16
17
  "@types/node": "^20.0.0",
17
- "tsx": "^4.19.4",
18
+ "tsx": "^4.22.4",
18
19
  "typescript": "^5.0.0"
19
20
  },
20
21
  "files": [
@@ -1,38 +0,0 @@
1
- ---
2
- name: satori-indexing
3
- description: Use when Satori codebases are not indexed, stale, blocked, still indexing, or need lifecycle recovery.
4
- ---
5
-
6
- # Satori Indexing
7
-
8
- Use this skill when the task is to create, reindex, sync, inspect readiness, or recover from stale index state.
9
-
10
- ## Tools
11
-
12
- Use only:
13
- 1. `list_codebases`
14
- 2. `manage_index`
15
-
16
- ## Workflow
17
-
18
- 1. Use `list_codebases` for a global view of tracked roots.
19
- 2. Use `manage_index(action="status", path=...)` for the specific codebase.
20
- 3. Use `manage_index(action="create", path=...)` when the codebase is not indexed.
21
- 4. Use `manage_index(action="reindex", path=...)` only for compatibility gates or explicit rebuilds.
22
- 5. Use `manage_index(action="sync", path=...)` for freshness convergence and ignore-rule updates.
23
-
24
- ## Rules
25
-
26
- - If any tool returns `requires_reindex`, stop and reindex. Do not substitute `sync`.
27
- - Never call `manage_index(action="clear")` unless the user explicitly requests destructive reset.
28
- - Treat ignore-only churn as a `sync` problem first.
29
- - Respect blocked and indexing states instead of forcing retries blindly.
30
- - Use `status` and `list_codebases` freely; provider credentials are only needed for provider-backed lifecycle actions.
31
-
32
- ## Status Handling
33
-
34
- - `requires_reindex`: run `manage_index(action="reindex")`.
35
- - `not_ready` with indexing reason: check status and wait for terminal completion.
36
- - `not_indexed`: create the index.
37
- - `MISSING_PROVIDER_CONFIG`: run `satori-cli doctor` when available, then set missing provider or Milvus env before retrying create/reindex/sync/search.
38
- - Ignore-rule noise mitigation: update `.satoriignore`, wait debounce, and run `sync` for immediate convergence.
@@ -1,39 +0,0 @@
1
- ---
2
- name: satori-navigation
3
- description: Use when search has returned candidate code and exact spans, symbol reads, or call relationships are needed.
4
- ---
5
-
6
- # Satori Navigation
7
-
8
- Use this skill after `search_codebase` has returned candidate results and you need exact symbol/file navigation.
9
-
10
- ## Tools
11
-
12
- Use only:
13
- 1. `file_outline`
14
- 2. `call_graph`
15
- 3. `read_file`
16
-
17
- For lifecycle remediation (`requires_reindex`, `not_indexed`, indexing waits), switch to `satori-indexing`.
18
-
19
- ## Workflow
20
-
21
- 1. Use grouped `search_codebase` results as the starting point.
22
- 2. Use `file_outline(resolveMode="exact", symbolIdExact|symbolLabelExact)` to lock the symbol span when exact identity is available.
23
- 3. If `callGraphHint.supported=true`, call `call_graph(path=..., symbolRef=..., direction="both", depth=1)`.
24
- 4. If `callGraphHint.supported=false`, execute `navigationFallback.readSpan.args` exactly.
25
- 5. Use `read_file(path=..., open_symbol=...)` or deterministic line spans for the final read.
26
-
27
- ## Rules
28
-
29
- - Treat `navigationFallback` as authoritative. Do not invent spans.
30
- - `open_symbol` must resolve deterministically. Do not guess on ambiguity.
31
- - `read_file(mode="annotated")` is preferred when outline metadata is useful.
32
- - Follow continuation hints when plain reads are truncated.
33
- - Other tools do not run search freshness; remediate stale index state before trusting navigation.
34
-
35
- ## Remediation
36
-
37
- - `requires_reindex`: switch to `satori-indexing` and reindex before retrying navigation.
38
- - `not_ready` or `not_indexed`: switch to `satori-indexing`; wait or create before retrying navigation.
39
- - `unsupported`: fall back to deterministic `read_file` spans when supplied by `navigationFallback`.
@@ -1,39 +0,0 @@
1
- ---
2
- name: satori-search
3
- description: Use when finding code by behavior, concept, or symbol before opening files or falling back to grep.
4
- ---
5
-
6
- # Satori Search
7
-
8
- Use this skill when the task is to find where behavior lives, identify candidate symbols, or narrow the search space before deeper navigation.
9
-
10
- ## Tools
11
-
12
- Use only:
13
- 1. `list_codebases`
14
- 2. `manage_index`
15
- 3. `search_codebase`
16
-
17
- ## Workflow
18
-
19
- 1. Check readiness with `manage_index(action="status", path=...)` when index state is unknown.
20
- 2. If not indexed, use `manage_index(action="create", path=...)`.
21
- 3. If `requires_reindex` appears, stop and use `manage_index(action="reindex", path=...)`, then retry.
22
- 4. Search the user-requested path with `search_codebase(path=..., query=..., scope="runtime", resultMode="grouped", groupBy="symbol", rankingMode="auto_changed_first")`.
23
-
24
- ## Search Rules
25
-
26
- - Start with natural-language intent, not filenames.
27
- - Default to `scope="runtime"`.
28
- - Use operators only when needed: `lang:`, `path:`, `-path:`, `must:`, `exclude:`.
29
- - Pass the user's requested path; if Satori resolves an indexed parent, follow returned `navigationFallback` exactly.
30
- - Treat warnings as usable-but-degraded results, not fatal errors.
31
- - Use `debug=true` only when ranking or filter explanations are required.
32
-
33
- ## Remediation
34
-
35
- - `requires_reindex`: run `manage_index(action="reindex")`, not `sync`.
36
- - `not_ready` with indexing reason: wait or check `manage_index(action="status")`.
37
- - `not_indexed`: run `manage_index(action="create")` on the repository root or requested indexed root.
38
- - `MISSING_PROVIDER_CONFIG` is active only when it appears as the tool response `code` or `reason`. If it appears inside `search_codebase` results, it may just be matched code content.
39
- - Noise mitigation hint: update `.satoriignore`, wait debounce, rerun search, and use `manage_index(action="sync")` only for immediate convergence.