@zokizuan/satori-cli 0.3.1 → 0.4.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 CHANGED
@@ -1,54 +1,63 @@
1
1
  # @zokizuan/satori-cli
2
2
 
3
- Shell CLI for Satori installation, skill packaging, and direct tool invocation 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
- ## What It Does
6
-
7
- - installs and removes Satori MCP client config for supported clients
8
- - copies packaged first-party skills:
9
- - `satori-search`
10
- - `satori-navigation`
11
- - `satori-indexing`
12
- - starts a local stdio session against `@zokizuan/satori-mcp` for direct shell workflows
13
-
14
- ## Install / Uninstall
5
+ ## Quick Start
15
6
 
16
7
  ```bash
17
- npx -y @zokizuan/satori-cli@0.3.1 install --client codex
18
- npx -y @zokizuan/satori-cli@0.3.1 install --client claude
19
- npx -y @zokizuan/satori-cli@0.3.1 install --client all --dry-run
20
- npx -y @zokizuan/satori-cli@0.3.1 uninstall --client codex
21
- npx -y @zokizuan/satori-cli@0.3.1 doctor
8
+ npx -y @zokizuan/satori-cli@0.4.0 install --client all
9
+ npx -y @zokizuan/satori-cli@0.4.0 doctor
22
10
  ```
23
11
 
24
- Managed install writes MCP config that launches:
12
+ Supported clients are `codex`, `claude`, `opencode`, and `all`.
25
13
 
26
- ```toml
27
- [mcp_servers.satori]
28
- command = "npx"
29
- args = ["-y", "@zokizuan/satori-mcp@4.9.1"]
30
- startup_timeout_ms = 180000
31
- ```
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
+ The installer only manages Satori-owned config and the first-party workflow skill:
17
+
18
+ - `satori`
32
19
 
33
20
  ## Commands
34
21
 
35
22
  ```bash
36
- satori-cli doctor
23
+ npx -y @zokizuan/satori-cli@0.4.0 install --client codex
24
+ npx -y @zokizuan/satori-cli@0.4.0 install --client claude
25
+ npx -y @zokizuan/satori-cli@0.4.0 install --client opencode
26
+ npx -y @zokizuan/satori-cli@0.4.0 install --client all --dry-run
27
+ npx -y @zokizuan/satori-cli@0.4.0 uninstall --client codex
28
+ ```
29
+
30
+ `doctor` checks Node, package visibility, provider env, and Milvus env without starting an MCP client.
31
+
32
+ ## Direct Tool Calls
33
+
34
+ ```bash
37
35
  satori-cli tools list
38
- satori-cli tool call <toolName> --args-json '{"path":"/abs/repo","query":"auth"}'
39
- satori-cli tool call <toolName> --args-file ./args.json
40
- satori-cli tool call <toolName> --args-json @-
41
- satori-cli <toolName> [schema-subset flags]
36
+ satori-cli tool call search_codebase --args-json '{"path":"/abs/repo","query":"auth flow"}'
37
+ satori-cli tool call search_codebase --args-file ./args.json
38
+ satori-cli tool call search_codebase --args-json @-
39
+ satori-cli search_codebase --path /abs/repo --query "auth flow"
42
40
  ```
43
41
 
44
- Global flags (`--startup-timeout-ms`, `--call-timeout-ms`, `--format`, `--debug`) must appear before the command token.
42
+ Global flags such as `--startup-timeout-ms`, `--call-timeout-ms`, `--format`, and `--debug` must appear before the command token.
43
+
44
+ ## Runtime Requirements
45
+
46
+ 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.
45
47
 
46
- `doctor` checks local setup without starting an MCP client: Node version, npm package visibility, provider env, and Milvus env.
48
+ Common local setup:
49
+
50
+ ```bash
51
+ EMBEDDING_PROVIDER=Ollama
52
+ EMBEDDING_MODEL=nomic-embed-text
53
+ OLLAMA_HOST=http://127.0.0.1:11434
54
+ MILVUS_ADDRESS=localhost:19530
55
+ ```
47
56
 
48
57
  ## Development
49
58
 
50
59
  ```bash
51
60
  pnpm --filter @zokizuan/satori-cli build
52
61
  pnpm --filter @zokizuan/satori-cli test
53
- pnpm --filter @zokizuan/satori-cli release:smoke
62
+ pnpm run release:smoke:cli
54
63
  ```
@@ -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/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,36 @@
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 INSTRUCTIONS_BLOCK_START = "<!-- satori-mcp:start -->";
12
+ const INSTRUCTIONS_BLOCK_END = "<!-- satori-mcp:end -->";
13
+ const OWNED_SKILL_DIRS = ["satori"];
14
+ const MANAGED_RUNTIME_DIR = "mcp-runtime";
15
+ const MANAGED_BIN_DIR = "bin";
16
+ const MANAGED_LAUNCHER_FILE = "satori-mcp.js";
17
+ const OPENCODE_INSTRUCTIONS = `# Satori MCP
18
+
19
+ This project uses Satori MCP for semantic code search, deterministic navigation, and index lifecycle management.
20
+
21
+ ## Priority Order
22
+ 1. \`search_codebase\` - find candidate code by behavior, concept, or symbol
23
+ 2. \`file_outline\` - lock exact symbol spans before reading or editing
24
+ 3. \`call_graph\` - inspect callers and callees when supported
25
+ 4. \`read_file\` - open exact spans or fallback windows
26
+ 5. \`manage_index\` - create, sync, reindex, or inspect index status
27
+
28
+ ## Rules
29
+ - Prefer Satori tools before grep/glob for code discovery.
30
+ - If a tool returns \`requires_reindex\`, run \`manage_index(action="reindex")\` and retry the original call.
31
+ - Treat \`navigationFallback\` as authoritative when call graph is unavailable.
32
+ - Read the relevant implementation and call sites before editing behavior.
33
+ `;
11
34
  function resolveDefaultSkillAssetRoot() {
12
35
  const currentFile = fileURLToPath(import.meta.url);
13
36
  return path.resolve(path.dirname(currentFile), "..", "assets", "skills");
@@ -26,12 +49,26 @@ function resolveClientTargets(homeDir) {
26
49
  {
27
50
  client: "codex",
28
51
  configPath: path.join(homeDir, ".codex", "config.toml"),
29
- skillsPath: path.join(homeDir, ".codex", "skills"),
52
+ companion: {
53
+ kind: "skills",
54
+ path: path.join(homeDir, ".codex", "skills"),
55
+ },
30
56
  },
31
57
  {
32
58
  client: "claude",
33
59
  configPath: path.join(homeDir, ".claude", "settings.json"),
34
- skillsPath: path.join(homeDir, ".claude", "skills"),
60
+ companion: {
61
+ kind: "skills",
62
+ path: path.join(homeDir, ".claude", "skills"),
63
+ },
64
+ },
65
+ {
66
+ client: "opencode",
67
+ configPath: path.join(homeDir, ".config", "opencode", "opencode.json"),
68
+ companion: {
69
+ kind: "instructions",
70
+ path: path.join(homeDir, ".config", "opencode", "AGENTS.md"),
71
+ },
35
72
  },
36
73
  ];
37
74
  }
@@ -60,13 +97,167 @@ function normalizeTrailingNewline(value) {
60
97
  function escapeRegExp(value) {
61
98
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
62
99
  }
63
- function buildCodexManagedBlock(packageSpecifier) {
100
+ function toTomlString(value) {
101
+ return JSON.stringify(value);
102
+ }
103
+ function buildTomlArray(values) {
104
+ return `[${values.map(toTomlString).join(", ")}]`;
105
+ }
106
+ function packageNameFromSpecifier(packageSpecifier) {
107
+ if (packageSpecifier.startsWith("@")) {
108
+ const versionMarker = packageSpecifier.indexOf("@", 1);
109
+ return versionMarker === -1 ? packageSpecifier : packageSpecifier.slice(0, versionMarker);
110
+ }
111
+ const versionMarker = packageSpecifier.indexOf("@");
112
+ return versionMarker === -1 ? packageSpecifier : packageSpecifier.slice(0, versionMarker);
113
+ }
114
+ function safeRuntimeDirName(packageSpecifier) {
115
+ return packageSpecifier.replace(/[^A-Za-z0-9._@-]+/g, "-");
116
+ }
117
+ function resolveRuntimeRoot(homeDir, packageSpecifier) {
118
+ return path.join(homeDir, ".satori", MANAGED_RUNTIME_DIR, safeRuntimeDirName(packageSpecifier));
119
+ }
120
+ function resolveRuntimePackageRoot(homeDir, packageSpecifier) {
121
+ return path.join(resolveRuntimeRoot(homeDir, packageSpecifier), "node_modules", ...packageNameFromSpecifier(packageSpecifier).split("/"));
122
+ }
123
+ function resolveRuntimeEntryPath(packageRoot, packageJson) {
124
+ const bin = packageJson?.bin;
125
+ let relativeEntry = "dist/index.js";
126
+ if (bin && typeof bin === "object" && !Array.isArray(bin) && typeof bin.satori === "string") {
127
+ relativeEntry = bin.satori;
128
+ }
129
+ else if (typeof bin === "string") {
130
+ relativeEntry = bin;
131
+ }
132
+ else if (typeof packageJson?.main === "string") {
133
+ relativeEntry = packageJson.main;
134
+ }
135
+ return path.resolve(packageRoot, relativeEntry);
136
+ }
137
+ function resolveLauncherPath(homeDir) {
138
+ return path.join(homeDir, ".satori", MANAGED_BIN_DIR, MANAGED_LAUNCHER_FILE);
139
+ }
140
+ function plannedManagedRuntimeCommand(homeDir, packageSpecifier) {
141
+ return {
142
+ command: process.execPath,
143
+ args: [resolveRuntimeEntryPath(resolveRuntimePackageRoot(homeDir, packageSpecifier))],
144
+ };
145
+ }
146
+ function managedClientCommand(homeDir) {
147
+ return {
148
+ command: process.execPath,
149
+ args: [resolveLauncherPath(homeDir)],
150
+ };
151
+ }
152
+ function buildLauncherScript(runtimeCommand) {
153
+ return [
154
+ "#!/usr/bin/env node",
155
+ "",
156
+ "const { spawn } = require(\"node:child_process\");",
157
+ "",
158
+ `const command = ${JSON.stringify(runtimeCommand.command)};`,
159
+ `const baseArgs = ${JSON.stringify(runtimeCommand.args)};`,
160
+ "const child = spawn(command, [...baseArgs, ...process.argv.slice(2)], {",
161
+ " stdio: \"inherit\",",
162
+ " env: process.env,",
163
+ "});",
164
+ "",
165
+ "child.on(\"error\", (error) => {",
166
+ " console.error(`Failed to start Satori MCP runtime: ${error.message}`);",
167
+ " process.exit(1);",
168
+ "});",
169
+ "",
170
+ "child.on(\"exit\", (code, signal) => {",
171
+ " if (signal) {",
172
+ " console.error(`Satori MCP runtime exited from signal ${signal}`);",
173
+ " process.exit(1);",
174
+ " }",
175
+ " process.exit(code ?? 0);",
176
+ "});",
177
+ "",
178
+ ].join("\n");
179
+ }
180
+ function writeTextFileAtomic(filePath, content, mode) {
181
+ ensureParentDir(filePath);
182
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
183
+ fs.writeFileSync(tempPath, content, "utf8");
184
+ if (mode !== undefined) {
185
+ fs.chmodSync(tempPath, mode);
186
+ }
187
+ fs.renameSync(tempPath, filePath);
188
+ }
189
+ function prepareLauncherInstall(homeDir, runtimeCommand) {
190
+ const launcherPath = resolveLauncherPath(homeDir);
191
+ const current = readTextIfExists(launcherPath);
192
+ const next = buildLauncherScript(runtimeCommand);
193
+ return {
194
+ changed: current !== next,
195
+ apply: () => {
196
+ if (current === next) {
197
+ return;
198
+ }
199
+ writeTextFileAtomic(launcherPath, next, 0o755);
200
+ },
201
+ };
202
+ }
203
+ function npmOutput(error) {
204
+ if (!(error instanceof Error)) {
205
+ return String(error);
206
+ }
207
+ const stdout = "stdout" in error && typeof error.stdout === "string"
208
+ ? error.stdout
209
+ : "";
210
+ const stderr = "stderr" in error && typeof error.stderr === "string"
211
+ ? error.stderr
212
+ : "";
213
+ return `${stdout}\n${stderr}\n${error.message}`.trim();
214
+ }
215
+ function installManagedRuntimeCommand(homeDir, packageSpecifier, execImpl) {
216
+ const runtimeRoot = resolveRuntimeRoot(homeDir, packageSpecifier);
217
+ ensureDir(runtimeRoot);
218
+ try {
219
+ execImpl("npm", [
220
+ "install",
221
+ "--prefix",
222
+ runtimeRoot,
223
+ "--omit=dev",
224
+ "--no-package-lock",
225
+ "--ignore-scripts",
226
+ "--no-audit",
227
+ "--no-fund",
228
+ packageSpecifier,
229
+ ], {
230
+ encoding: "utf8",
231
+ stdio: ["ignore", "pipe", "pipe"],
232
+ });
233
+ }
234
+ catch (error) {
235
+ throw new CliError("E_USAGE", `Failed to install Satori MCP runtime package ${packageSpecifier} into ${runtimeRoot}. ${npmOutput(error)}`, 2);
236
+ }
237
+ const packageRoot = resolveRuntimePackageRoot(homeDir, packageSpecifier);
238
+ const packageJsonPath = path.join(packageRoot, "package.json");
239
+ let packageJson;
240
+ try {
241
+ packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
242
+ }
243
+ catch (error) {
244
+ throw new CliError("E_USAGE", `Installed Satori MCP runtime is missing package metadata at ${packageJsonPath}: ${error.message}`, 2);
245
+ }
246
+ const command = {
247
+ command: process.execPath,
248
+ args: [resolveRuntimeEntryPath(packageRoot, packageJson)],
249
+ };
250
+ if (!fs.existsSync(command.args[0])) {
251
+ throw new CliError("E_USAGE", `Installed Satori MCP runtime entry does not exist: ${command.args[0]}`, 2);
252
+ }
253
+ return command;
254
+ }
255
+ function buildCodexManagedBlock(runtimeCommand) {
64
256
  return [
65
257
  MANAGED_BLOCK_START,
66
258
  "[mcp_servers.satori]",
67
- 'command = "npx"',
68
- `args = ["-y", "${packageSpecifier}"]`,
69
- `startup_timeout_ms = ${MANAGED_TIMEOUT_MS}`,
259
+ `command = ${toTomlString(runtimeCommand.command)}`,
260
+ `args = ${buildTomlArray(runtimeCommand.args)}`,
70
261
  MANAGED_BLOCK_END,
71
262
  "",
72
263
  ].join("\n");
@@ -77,12 +268,12 @@ function codexHasUnmanagedSatoriSection(content) {
77
268
  }
78
269
  return !(content.includes(MANAGED_BLOCK_START) && content.includes(MANAGED_BLOCK_END));
79
270
  }
80
- function prepareCodexInstall(filePath, packageSpecifier) {
271
+ function prepareCodexInstall(filePath, runtimeCommand) {
81
272
  const current = readTextIfExists(filePath) ?? "";
82
273
  if (codexHasUnmanagedSatoriSection(current)) {
83
274
  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
275
  }
85
- const managedBlock = buildCodexManagedBlock(packageSpecifier);
276
+ const managedBlock = buildCodexManagedBlock(runtimeCommand);
86
277
  let next = current;
87
278
  if (current.includes(MANAGED_BLOCK_START) && current.includes(MANAGED_BLOCK_END)) {
88
279
  next = current.replace(new RegExp(`${escapeRegExp(MANAGED_BLOCK_START)}[\\s\\S]*?${escapeRegExp(MANAGED_BLOCK_END)}\\n?`, "m"), managedBlock);
@@ -146,45 +337,36 @@ function parseJsonObject(filePath) {
146
337
  }
147
338
  return parsed;
148
339
  }
149
- function buildClaudeServerConfig(packageSpecifier) {
340
+ function buildClaudeServerConfig(runtimeCommand) {
150
341
  return {
151
- command: "npx",
152
- args: ["-y", packageSpecifier],
153
- timeout: MANAGED_TIMEOUT_MS,
342
+ command: runtimeCommand.command,
343
+ args: runtimeCommand.args,
154
344
  };
155
345
  }
156
- function isManagedPackageSpecifier(value) {
157
- return typeof value === "string" && /^@zokizuan\/satori-mcp@.+$/.test(value);
346
+ function isManagedLauncherPath(value) {
347
+ return typeof value === "string" && value.replace(/\\/g, "/").endsWith(`/.satori/${MANAGED_BIN_DIR}/${MANAGED_LAUNCHER_FILE}`);
348
+ }
349
+ function isManagedCommandParts(command, args) {
350
+ if (!Array.isArray(args)) {
351
+ return false;
352
+ }
353
+ const entryPath = args[0];
354
+ return typeof command === "string"
355
+ && command.length > 0
356
+ && args.length === 1
357
+ && isManagedLauncherPath(entryPath);
158
358
  }
159
359
  function isManagedClaudeEntry(value) {
160
360
  if (!value || typeof value !== "object" || Array.isArray(value)) {
161
361
  return false;
162
362
  }
163
363
  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;
364
+ return isManagedCommandParts(entry.command, entry.args);
183
365
  }
184
- function prepareClaudeInstall(filePath, packageSpecifier) {
366
+ function prepareClaudeInstall(filePath, runtimeCommand) {
185
367
  const currentObject = parseJsonObject(filePath);
186
368
  const currentSerialized = JSON.stringify(currentObject);
187
- const desiredServer = buildClaudeServerConfig(packageSpecifier);
369
+ const desiredServer = buildClaudeServerConfig(runtimeCommand);
188
370
  const mcpServersValue = currentObject.mcpServers;
189
371
  let mcpServers;
190
372
  if (mcpServersValue === undefined) {
@@ -198,12 +380,13 @@ function prepareClaudeInstall(filePath, packageSpecifier) {
198
380
  }
199
381
  const existingSatori = mcpServers.satori;
200
382
  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);
383
+ 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
384
  }
203
385
  mcpServers.satori = {
204
386
  ...existingSatori,
205
387
  ...desiredServer,
206
388
  };
389
+ delete mcpServers.satori.timeout;
207
390
  currentObject.mcpServers = mcpServers;
208
391
  const next = `${JSON.stringify(currentObject, null, 2)}\n`;
209
392
  return {
@@ -245,6 +428,87 @@ function prepareClaudeUninstall(filePath) {
245
428
  },
246
429
  };
247
430
  }
431
+ function parseJsoncObject(filePath, content) {
432
+ const errors = [];
433
+ const parsed = parseJsonc(content, errors, { allowTrailingComma: true, disallowComments: false });
434
+ if (errors.length > 0) {
435
+ throw new CliError("E_USAGE", `Failed to parse JSONC config at ${filePath}.`, 2);
436
+ }
437
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
438
+ throw new CliError("E_USAGE", `Expected top-level JSON object in ${filePath}.`, 2);
439
+ }
440
+ return parsed;
441
+ }
442
+ function buildOpenCodeServerConfig(runtimeCommand) {
443
+ return {
444
+ enabled: true,
445
+ type: "local",
446
+ command: [runtimeCommand.command, ...runtimeCommand.args],
447
+ };
448
+ }
449
+ function isManagedOpenCodeEntry(value) {
450
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
451
+ return false;
452
+ }
453
+ const entry = value;
454
+ if (Array.isArray(entry.command)) {
455
+ const [command, ...args] = entry.command;
456
+ return isManagedCommandParts(command, args);
457
+ }
458
+ return isManagedCommandParts(entry.command, entry.args);
459
+ }
460
+ function mutateJsonc(filePath, current, pathSegments, value) {
461
+ const edits = modify(current, pathSegments, value, {
462
+ formattingOptions: {
463
+ insertSpaces: true,
464
+ tabSize: 2,
465
+ eol: "\n",
466
+ },
467
+ });
468
+ const next = applyEdits(current, edits);
469
+ return {
470
+ changed: next !== current,
471
+ apply: () => {
472
+ if (next === current) {
473
+ return;
474
+ }
475
+ ensureParentDir(filePath);
476
+ fs.writeFileSync(filePath, next.endsWith("\n") ? next : `${next}\n`, "utf8");
477
+ },
478
+ };
479
+ }
480
+ function prepareOpenCodeInstall(filePath, runtimeCommand) {
481
+ const current = readTextIfExists(filePath) ?? "{}\n";
482
+ const currentObject = parseJsoncObject(filePath, current);
483
+ const mcpValue = currentObject.mcp;
484
+ if (mcpValue !== undefined && (!mcpValue || typeof mcpValue !== "object" || Array.isArray(mcpValue))) {
485
+ throw new CliError("E_USAGE", `Expected mcp to be an object in ${filePath}.`, 2);
486
+ }
487
+ const existingSatori = mcpValue?.satori;
488
+ if (existingSatori !== undefined && !isManagedOpenCodeEntry(existingSatori)) {
489
+ 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);
490
+ }
491
+ return mutateJsonc(filePath, current, ["mcp", "satori"], buildOpenCodeServerConfig(runtimeCommand));
492
+ }
493
+ function prepareOpenCodeUninstall(filePath) {
494
+ const current = readTextIfExists(filePath);
495
+ if (!current) {
496
+ return { changed: false, apply: () => { } };
497
+ }
498
+ const currentObject = parseJsoncObject(filePath, current);
499
+ const mcpValue = currentObject.mcp;
500
+ if (!mcpValue || typeof mcpValue !== "object" || Array.isArray(mcpValue)) {
501
+ return { changed: false, apply: () => { } };
502
+ }
503
+ const existingSatori = mcpValue.satori;
504
+ if (existingSatori === undefined) {
505
+ return { changed: false, apply: () => { } };
506
+ }
507
+ if (!isManagedOpenCodeEntry(existingSatori)) {
508
+ throw new CliError("E_USAGE", `Refusing to remove unmanaged Satori config in ${filePath}. Remove mcp.satori manually instead.`, 2);
509
+ }
510
+ return mutateJsonc(filePath, current, ["mcp", "satori"], undefined);
511
+ }
248
512
  function prepareSkillInstall(skillsPath, skillAssetRoot) {
249
513
  const writes = [];
250
514
  let changed = false;
@@ -284,24 +548,92 @@ function prepareSkillRemoval(skillsPath) {
284
548
  },
285
549
  };
286
550
  }
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)
551
+ function buildManagedInstructionsBlock() {
552
+ return [
553
+ INSTRUCTIONS_BLOCK_START,
554
+ OPENCODE_INSTRUCTIONS.trim(),
555
+ INSTRUCTIONS_BLOCK_END,
556
+ "",
557
+ ].join("\n");
558
+ }
559
+ function prepareInstructionsInstall(filePath) {
560
+ const current = readTextIfExists(filePath) ?? "";
561
+ const block = buildManagedInstructionsBlock();
562
+ let next = current;
563
+ if (current.includes(INSTRUCTIONS_BLOCK_START) && current.includes(INSTRUCTIONS_BLOCK_END)) {
564
+ next = current.replace(new RegExp(`${escapeRegExp(INSTRUCTIONS_BLOCK_START)}[\\s\\S]*?${escapeRegExp(INSTRUCTIONS_BLOCK_END)}\\n?`, "m"), block);
565
+ }
566
+ else if (current.trim().length === 0) {
567
+ next = block;
568
+ }
569
+ else {
570
+ next = `${normalizeTrailingNewline(current)}\n${block}`;
571
+ }
572
+ return {
573
+ changed: next !== current,
574
+ apply: () => {
575
+ if (next === current) {
576
+ return;
577
+ }
578
+ ensureParentDir(filePath);
579
+ fs.writeFileSync(filePath, next, "utf8");
580
+ },
581
+ };
582
+ }
583
+ function prepareInstructionsRemoval(filePath) {
584
+ const current = readTextIfExists(filePath);
585
+ if (!current || !current.includes(INSTRUCTIONS_BLOCK_START) || !current.includes(INSTRUCTIONS_BLOCK_END)) {
586
+ return { changed: false, apply: () => { } };
587
+ }
588
+ const next = current
589
+ .replace(new RegExp(`\\n?${escapeRegExp(INSTRUCTIONS_BLOCK_START)}[\\s\\S]*?${escapeRegExp(INSTRUCTIONS_BLOCK_END)}\\n?`, "m"), "\n")
590
+ .replace(/\n{3,}/g, "\n\n")
591
+ .replace(/^\n+/, "");
592
+ return {
593
+ changed: next !== current,
594
+ apply: () => {
595
+ if (next === current) {
596
+ return;
597
+ }
598
+ fs.writeFileSync(filePath, next, "utf8");
599
+ },
600
+ };
601
+ }
602
+ function prepareCompanionMutation(companion, command, skillAssetRoot) {
603
+ if (companion.kind === "skills") {
604
+ return command.kind === "install"
605
+ ? prepareSkillInstall(companion.path, skillAssetRoot)
606
+ : prepareSkillRemoval(companion.path);
607
+ }
608
+ return command.kind === "install"
609
+ ? prepareInstructionsInstall(companion.path)
610
+ : prepareInstructionsRemoval(companion.path);
611
+ }
612
+ function prepareConfigMutation(target, command, runtimeCommand) {
613
+ if (target.client === "codex") {
614
+ return command.kind === "install"
615
+ ? prepareCodexInstall(target.configPath, runtimeCommand)
616
+ : prepareCodexUninstall(target.configPath);
617
+ }
618
+ if (target.client === "claude") {
619
+ return command.kind === "install"
620
+ ? prepareClaudeInstall(target.configPath, runtimeCommand)
294
621
  : prepareClaudeUninstall(target.configPath);
295
- const skillsMutation = command.kind === "install"
296
- ? prepareSkillInstall(target.skillsPath, skillAssetRoot)
297
- : prepareSkillRemoval(target.skillsPath);
622
+ }
623
+ return command.kind === "install"
624
+ ? prepareOpenCodeInstall(target.configPath, runtimeCommand)
625
+ : prepareOpenCodeUninstall(target.configPath);
626
+ }
627
+ function prepareMutation(target, command, runtimeCommand, skillAssetRoot) {
628
+ const configMutation = prepareConfigMutation(target, command, runtimeCommand);
629
+ const companionMutation = prepareCompanionMutation(target.companion, command, skillAssetRoot);
298
630
  return {
299
631
  target,
300
632
  configChanged: configMutation.changed,
301
- skillsChanged: skillsMutation.changed,
633
+ companionChanged: companionMutation.changed,
302
634
  apply: () => {
303
635
  configMutation.apply();
304
- skillsMutation.apply();
636
+ companionMutation.apply();
305
637
  },
306
638
  };
307
639
  }
@@ -309,8 +641,20 @@ export function executeInstallCommand(command, options = {}) {
309
641
  const homeDir = options.homeDir ?? os.homedir();
310
642
  const packageSpecifier = options.packageSpecifier ?? resolveDefaultPackageSpecifier();
311
643
  const skillAssetRoot = options.skillAssetRoot ?? resolveDefaultSkillAssetRoot();
312
- const prepared = selectTargets(homeDir, command.client).map((target) => (prepareMutation(target, command, packageSpecifier, skillAssetRoot)));
644
+ const plannedRuntimeCommand = options.runtimeCommand ?? plannedManagedRuntimeCommand(homeDir, packageSpecifier);
645
+ const clientCommand = managedClientCommand(homeDir);
646
+ let launcherMutation = command.kind === "install"
647
+ ? prepareLauncherInstall(homeDir, plannedRuntimeCommand)
648
+ : { changed: false, apply: () => { } };
649
+ const prepared = selectTargets(homeDir, command.client).map((target) => (prepareMutation(target, command, clientCommand, skillAssetRoot)));
313
650
  if (!command.dryRun) {
651
+ if (command.kind === "install" && !options.runtimeCommand) {
652
+ const installedRuntimeCommand = installManagedRuntimeCommand(homeDir, packageSpecifier, options.execFileSyncImpl ?? execFileSync);
653
+ launcherMutation = prepareLauncherInstall(homeDir, installedRuntimeCommand);
654
+ }
655
+ if (command.kind === "install") {
656
+ launcherMutation.apply();
657
+ }
314
658
  for (const mutation of prepared) {
315
659
  mutation.apply();
316
660
  }
@@ -322,10 +666,12 @@ export function executeInstallCommand(command, options = {}) {
322
666
  results: prepared.map((mutation) => ({
323
667
  client: mutation.target.client,
324
668
  configPath: mutation.target.configPath,
325
- skillsPath: mutation.target.skillsPath,
669
+ skillsPath: mutation.target.companion.kind === "skills" ? mutation.target.companion.path : undefined,
670
+ instructionsPath: mutation.target.companion.kind === "instructions" ? mutation.target.companion.path : undefined,
326
671
  configChanged: mutation.configChanged,
327
- skillsChanged: mutation.skillsChanged,
328
- status: mutation.configChanged || mutation.skillsChanged ? "updated" : "unchanged",
672
+ skillsChanged: mutation.target.companion.kind === "skills" ? mutation.companionChanged : false,
673
+ instructionsChanged: mutation.target.companion.kind === "instructions" ? mutation.companionChanged : false,
674
+ status: mutation.configChanged || mutation.companionChanged || launcherMutation.changed ? "updated" : "unchanged",
329
675
  dryRun: command.dryRun,
330
676
  })),
331
677
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zokizuan/satori-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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.9.1"
12
+ "@modelcontextprotocol/sdk": "^1.29.0",
13
+ "jsonc-parser": "^3.3.1",
14
+ "@zokizuan/satori-mcp": "4.11.0"
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.