@bilalimamoglu/sift 0.1.0 → 0.2.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
@@ -48,8 +48,8 @@ Set credentials once in your shell:
48
48
 
49
49
  ```bash
50
50
  export SIFT_BASE_URL=https://api.openai.com/v1
51
- export SIFT_API_KEY=your_api_key
52
51
  export SIFT_MODEL=gpt-4.1-mini
52
+ export OPENAI_API_KEY=your_openai_api_key
53
53
  ```
54
54
 
55
55
  Or write them to a config file:
@@ -58,7 +58,20 @@ Or write them to a config file:
58
58
  sift config init
59
59
  ```
60
60
 
61
- `sift` is remote-first today. The safe path is to set `SIFT_API_KEY`, `SIFT_BASE_URL`, and `SIFT_MODEL` once, then run `sift` normally.
61
+ For the default OpenAI-compatible setup, `OPENAI_API_KEY` works directly. If you point `SIFT_BASE_URL` at a different compatible endpoint, use that provider's native key when `sift` recognizes the endpoint, or set the generic fallback env:
62
+
63
+ ```bash
64
+ export SIFT_PROVIDER_API_KEY=your_provider_api_key
65
+ ```
66
+
67
+ `SIFT_PROVIDER_API_KEY` is the generic wrapper env for custom or self-hosted compatible endpoints. Today's `openai-compatible` mode stays generic and does not imply OpenAI ownership.
68
+
69
+ Known native env fallbacks for recognized compatible endpoints:
70
+
71
+ - `OPENAI_API_KEY` for `https://api.openai.com/v1`
72
+ - `OPENROUTER_API_KEY` for `https://openrouter.ai/api/v1`
73
+ - `TOGETHER_API_KEY` for `https://api.together.xyz/v1`
74
+ - `GROQ_API_KEY` for `https://api.groq.com/openai/v1`
62
75
 
63
76
  ## Quick start
64
77
 
@@ -77,6 +90,7 @@ sift exec --preset infra-risk -- terraform plan
77
90
  sift exec "did tests pass?" -- pytest
78
91
  sift exec "what changed?" -- git diff
79
92
  sift exec --preset infra-risk -- terraform plan
93
+ sift exec --dry-run "what changed?" -- git diff
80
94
  ```
81
95
 
82
96
  What happens:
@@ -88,6 +102,8 @@ What happens:
88
102
  5. It prints a short answer or JSON.
89
103
  6. It preserves the wrapped command's exit code.
90
104
 
105
+ Use `--dry-run` to inspect the reduced input and prompt without calling the provider.
106
+
91
107
  ## Pipe mode
92
108
 
93
109
  If the output already exists in a pipeline, pipe mode still works:
@@ -125,6 +141,20 @@ sift presets show audit-critical
125
141
 
126
142
  Some built-in presets also use local heuristics before calling a model. For example, `infra-risk` can mark obvious destructive plans as `fail` without sending the whole decision to the model.
127
143
 
144
+ ## JSON response format
145
+
146
+ When `format` resolves to JSON, `sift` can ask the provider for native JSON output.
147
+
148
+ - `auto`: enable native JSON mode only for known-safe endpoints such as `https://api.openai.com/v1`
149
+ - `on`: always send the native JSON response format request
150
+ - `off`: never send it
151
+
152
+ Example:
153
+
154
+ ```bash
155
+ sift exec --format json --json-response-format on "summarize this" -- some-command
156
+ ```
157
+
128
158
  ## Config
129
159
 
130
160
  Generate an example config:
@@ -150,7 +180,11 @@ Supported environment variables:
150
180
  - `SIFT_PROVIDER`
151
181
  - `SIFT_MODEL`
152
182
  - `SIFT_BASE_URL`
153
- - `SIFT_API_KEY`
183
+ - `SIFT_PROVIDER_API_KEY`
184
+ - `OPENAI_API_KEY` for `https://api.openai.com/v1`
185
+ - `OPENROUTER_API_KEY` for `https://openrouter.ai/api/v1`
186
+ - `TOGETHER_API_KEY` for `https://api.together.xyz/v1`
187
+ - `GROQ_API_KEY` for `https://api.groq.com/openai/v1`
154
188
  - `SIFT_MAX_CAPTURE_CHARS`
155
189
  - `SIFT_TIMEOUT_MS`
156
190
  - `SIFT_MAX_INPUT_CHARS`
@@ -198,6 +232,34 @@ sift presets list
198
232
  sift presets show <name>
199
233
  ```
200
234
 
235
+ ## Releasing
236
+
237
+ `sift` uses a manual GitHub Actions release workflow with npm trusted publishing.
238
+
239
+ Before the first release:
240
+
241
+ 1. configure npm trusted publishing for `@bilalimamoglu/sift`
242
+ 2. point it at `bilalimamoglu/sift`
243
+ 3. use the workflow filename `release.yml`
244
+ 4. set the GitHub Actions environment name to `release`
245
+
246
+ For each release:
247
+
248
+ 1. update `package.json` to the target version
249
+ 2. merge the final release commit to `main`
250
+ 3. open GitHub Actions and run the `release` workflow manually
251
+
252
+ The workflow will:
253
+
254
+ 1. install dependencies
255
+ 2. typecheck, test, and build
256
+ 3. pack and smoke-test the tarball
257
+ 4. publish to npm
258
+ 5. create and push the `vX.Y.Z` tag
259
+ 6. create a GitHub Release
260
+
261
+ `release.yml` uses OIDC trusted publishing, so it does not require an `NPM_TOKEN`.
262
+
201
263
  ## Using it with Codex
202
264
 
203
265
  `sift` does not install itself into Codex. The normal setup is:
@@ -226,6 +288,7 @@ That gives the agent a simple habit:
226
288
  - Redaction is optional and regex-based.
227
289
  - Redaction is off by default. If command output may contain secrets, enable `--redact` or set it in config before sending output to a provider.
228
290
  - Built-in JSON and verdict flows return strict error objects on provider/model failure.
291
+ - Retriable provider failures such as `429`, timeouts, and `5xx` responses are retried once before falling back.
229
292
  - `sift exec` detects simple prompt-like output such as `[y/N]` or `password:` and skips reduction instead of guessing.
230
293
  - Pipe mode does not preserve upstream shell pipeline failures; use `set -o pipefail` if you need that behavior.
231
294
  - `sift exec` mirrors the wrapped command's exit code.
package/dist/cli.js CHANGED
@@ -55,6 +55,7 @@ var defaultConfig = {
55
55
  model: "gpt-4.1-mini",
56
56
  baseUrl: "https://api.openai.com/v1",
57
57
  apiKey: "",
58
+ jsonResponseFormat: "auto",
58
59
  timeoutMs: 2e4,
59
60
  temperature: 0.1,
60
61
  maxOutputTokens: 220
@@ -108,6 +109,70 @@ var defaultConfig = {
108
109
  }
109
110
  };
110
111
 
112
+ // src/config/provider-api-key.ts
113
+ var OPENAI_COMPATIBLE_BASE_URL_ENV = [
114
+ { prefix: "https://api.openai.com/", envName: "OPENAI_API_KEY" },
115
+ { prefix: "https://openrouter.ai/api/", envName: "OPENROUTER_API_KEY" },
116
+ { prefix: "https://api.together.xyz/", envName: "TOGETHER_API_KEY" },
117
+ { prefix: "https://api.groq.com/openai/", envName: "GROQ_API_KEY" }
118
+ ];
119
+ var PROVIDER_API_KEY_ENV = {
120
+ anthropic: "ANTHROPIC_API_KEY",
121
+ claude: "ANTHROPIC_API_KEY",
122
+ groq: "GROQ_API_KEY",
123
+ openai: "OPENAI_API_KEY",
124
+ openrouter: "OPENROUTER_API_KEY",
125
+ together: "TOGETHER_API_KEY"
126
+ };
127
+ function normalizeBaseUrl(baseUrl) {
128
+ if (!baseUrl) {
129
+ return void 0;
130
+ }
131
+ return `${baseUrl.replace(/\/+$/, "")}/`.toLowerCase();
132
+ }
133
+ function resolveCompatibleEnvName(baseUrl) {
134
+ const normalized = normalizeBaseUrl(baseUrl);
135
+ if (!normalized) {
136
+ return void 0;
137
+ }
138
+ const match = OPENAI_COMPATIBLE_BASE_URL_ENV.find(
139
+ (entry) => normalized.startsWith(entry.prefix)
140
+ );
141
+ return match?.envName;
142
+ }
143
+ function resolveProviderApiKey(provider, baseUrl, env) {
144
+ if (env.SIFT_PROVIDER_API_KEY) {
145
+ return env.SIFT_PROVIDER_API_KEY;
146
+ }
147
+ if (provider === "openai-compatible") {
148
+ const envName2 = resolveCompatibleEnvName(baseUrl);
149
+ return envName2 ? env[envName2] : void 0;
150
+ }
151
+ if (!provider) {
152
+ return void 0;
153
+ }
154
+ const envName = PROVIDER_API_KEY_ENV[provider];
155
+ return envName ? env[envName] : void 0;
156
+ }
157
+ function getProviderApiKeyEnvNames(provider, baseUrl) {
158
+ const envNames = ["SIFT_PROVIDER_API_KEY"];
159
+ if (provider === "openai-compatible") {
160
+ const envName2 = resolveCompatibleEnvName(baseUrl);
161
+ if (envName2) {
162
+ envNames.push(envName2);
163
+ }
164
+ return envNames;
165
+ }
166
+ if (!provider) {
167
+ return envNames;
168
+ }
169
+ const envName = PROVIDER_API_KEY_ENV[provider];
170
+ if (envName) {
171
+ envNames.push(envName);
172
+ }
173
+ return envNames;
174
+ }
175
+
111
176
  // src/config/schema.ts
112
177
  import { z } from "zod";
113
178
  var providerNameSchema = z.enum(["openai-compatible"]);
@@ -118,6 +183,7 @@ var outputFormatSchema = z.enum([
118
183
  "verdict"
119
184
  ]);
120
185
  var responseModeSchema = z.enum(["text", "json"]);
186
+ var jsonResponseFormatModeSchema = z.enum(["auto", "on", "off"]);
121
187
  var promptPolicyNameSchema = z.enum([
122
188
  "test-status",
123
189
  "audit-critical",
@@ -131,6 +197,7 @@ var providerConfigSchema = z.object({
131
197
  model: z.string().min(1),
132
198
  baseUrl: z.string().url(),
133
199
  apiKey: z.string().optional(),
200
+ jsonResponseFormat: jsonResponseFormatModeSchema,
134
201
  timeoutMs: z.number().int().positive(),
135
202
  temperature: z.number().min(0).max(2),
136
203
  maxOutputTokens: z.number().int().positive()
@@ -184,14 +251,25 @@ function mergeDefined(base, override) {
184
251
  }
185
252
  return result;
186
253
  }
187
- function buildEnvOverrides(env) {
254
+ function stripApiKey(overrides) {
255
+ if (!overrides?.provider || overrides.provider.apiKey === void 0) {
256
+ return overrides;
257
+ }
258
+ return {
259
+ ...overrides,
260
+ provider: {
261
+ ...overrides.provider,
262
+ apiKey: void 0
263
+ }
264
+ };
265
+ }
266
+ function buildNonCredentialEnvOverrides(env) {
188
267
  const overrides = {};
189
- if (env.SIFT_PROVIDER || env.SIFT_MODEL || env.SIFT_BASE_URL || env.SIFT_API_KEY || env.SIFT_TIMEOUT_MS) {
268
+ if (env.SIFT_PROVIDER || env.SIFT_MODEL || env.SIFT_BASE_URL || env.SIFT_TIMEOUT_MS) {
190
269
  overrides.provider = {
191
270
  provider: env.SIFT_PROVIDER,
192
271
  model: env.SIFT_MODEL,
193
272
  baseUrl: env.SIFT_BASE_URL,
194
- apiKey: env.SIFT_API_KEY,
195
273
  timeoutMs: env.SIFT_TIMEOUT_MS ? Number(env.SIFT_TIMEOUT_MS) : void 0
196
274
  };
197
275
  }
@@ -203,12 +281,40 @@ function buildEnvOverrides(env) {
203
281
  }
204
282
  return overrides;
205
283
  }
284
+ function buildCredentialEnvOverrides(env, context) {
285
+ const apiKey = resolveProviderApiKey(context.provider, context.baseUrl, env);
286
+ if (apiKey === void 0) {
287
+ return {};
288
+ }
289
+ return {
290
+ provider: {
291
+ apiKey
292
+ }
293
+ };
294
+ }
206
295
  function resolveConfig(options = {}) {
207
296
  const env = options.env ?? process.env;
208
297
  const fileConfig = loadRawConfig(options.configPath);
209
- const envConfig = buildEnvOverrides(env);
298
+ const nonCredentialEnvConfig = buildNonCredentialEnvOverrides(env);
299
+ const contextConfig = mergeDefined(
300
+ mergeDefined(
301
+ mergeDefined(defaultConfig, fileConfig),
302
+ nonCredentialEnvConfig
303
+ ),
304
+ stripApiKey(options.cliOverrides) ?? {}
305
+ );
306
+ const credentialEnvConfig = buildCredentialEnvOverrides(env, {
307
+ provider: contextConfig.provider.provider,
308
+ baseUrl: contextConfig.provider.baseUrl
309
+ });
210
310
  const merged = mergeDefined(
211
- mergeDefined(mergeDefined(defaultConfig, fileConfig), envConfig),
311
+ mergeDefined(
312
+ mergeDefined(
313
+ mergeDefined(defaultConfig, fileConfig),
314
+ nonCredentialEnvConfig
315
+ ),
316
+ credentialEnvConfig
317
+ ),
212
318
  options.cliOverrides ?? {}
213
319
  );
214
320
  return siftConfigSchema.parse(merged);
@@ -269,7 +375,7 @@ function configValidate(configPath) {
269
375
  });
270
376
  const resolvedPath = findConfigPath(configPath);
271
377
  process.stdout.write(
272
- `Config is valid${resolvedPath ? ` (${resolvedPath})` : " (using defaults)"}.
378
+ `Resolved config is valid${resolvedPath ? ` (${resolvedPath})` : " (using defaults)"}.
273
379
  `
274
380
  );
275
381
  }
@@ -278,6 +384,7 @@ function configValidate(configPath) {
278
384
  function runDoctor(config) {
279
385
  const lines = [
280
386
  "sift doctor",
387
+ "mode: local config completeness check",
281
388
  `provider: ${config.provider.provider}`,
282
389
  `model: ${config.provider.model}`,
283
390
  `baseUrl: ${config.provider.baseUrl}`,
@@ -297,6 +404,12 @@ function runDoctor(config) {
297
404
  }
298
405
  if (config.provider.provider === "openai-compatible" && !config.provider.apiKey) {
299
406
  problems.push("Missing provider.apiKey");
407
+ problems.push(
408
+ `Set one of: ${getProviderApiKeyEnvNames(
409
+ config.provider.provider,
410
+ config.provider.baseUrl
411
+ ).join(", ")}`
412
+ );
300
413
  }
301
414
  if (problems.length > 0) {
302
415
  process.stderr.write(`${problems.join("\n")}
@@ -335,6 +448,15 @@ import pc2 from "picocolors";
335
448
  import pc from "picocolors";
336
449
 
337
450
  // src/providers/openaiCompatible.ts
451
+ function supportsNativeJsonResponseFormat(baseUrl, mode) {
452
+ if (mode === "off") {
453
+ return false;
454
+ }
455
+ if (mode === "on") {
456
+ return true;
457
+ }
458
+ return /^https:\/\/api\.openai\.com(?:\/|$)/i.test(baseUrl);
459
+ }
338
460
  function extractMessageText(payload) {
339
461
  const content = payload?.choices?.[0]?.message?.content;
340
462
  if (typeof content === "string") {
@@ -369,6 +491,7 @@ var OpenAICompatibleProvider = class {
369
491
  model: input.model,
370
492
  temperature: input.temperature,
371
493
  max_tokens: input.maxOutputTokens,
494
+ ...input.responseMode === "json" && supportsNativeJsonResponseFormat(this.baseUrl, input.jsonResponseFormat) ? { response_format: { type: "json_object" } } : {},
372
495
  messages: [
373
496
  {
374
497
  role: "system",
@@ -879,6 +1002,7 @@ function prepareInput(raw, config) {
879
1002
  }
880
1003
 
881
1004
  // src/core/run.ts
1005
+ var RETRY_DELAY_MS = 300;
882
1006
  function normalizeOutput(text, responseMode) {
883
1007
  if (responseMode !== "json") {
884
1008
  return text.trim();
@@ -890,6 +1014,68 @@ function normalizeOutput(text, responseMode) {
890
1014
  throw new Error("Provider returned invalid JSON");
891
1015
  }
892
1016
  }
1017
+ function buildDryRunOutput(args) {
1018
+ return JSON.stringify(
1019
+ {
1020
+ status: "dry-run",
1021
+ strategy: args.heuristicOutput ? "heuristic" : "provider",
1022
+ provider: {
1023
+ name: args.providerName,
1024
+ model: args.request.config.provider.model,
1025
+ baseUrl: args.request.config.provider.baseUrl,
1026
+ jsonResponseFormat: args.request.config.provider.jsonResponseFormat
1027
+ },
1028
+ question: args.request.question,
1029
+ format: args.request.format,
1030
+ responseMode: args.responseMode,
1031
+ policy: args.request.policyName ?? null,
1032
+ heuristicOutput: args.heuristicOutput ?? null,
1033
+ input: {
1034
+ originalLength: args.prepared.meta.originalLength,
1035
+ finalLength: args.prepared.meta.finalLength,
1036
+ redactionApplied: args.prepared.meta.redactionApplied,
1037
+ truncatedApplied: args.prepared.meta.truncatedApplied,
1038
+ text: args.prepared.truncated
1039
+ },
1040
+ prompt: args.prompt
1041
+ },
1042
+ null,
1043
+ 2
1044
+ );
1045
+ }
1046
+ async function delay(ms) {
1047
+ await new Promise((resolve) => setTimeout(resolve, ms));
1048
+ }
1049
+ async function generateWithRetry(args) {
1050
+ let lastError;
1051
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1052
+ try {
1053
+ return await args.provider.generate({
1054
+ model: args.request.config.provider.model,
1055
+ prompt: args.prompt,
1056
+ temperature: args.request.config.provider.temperature,
1057
+ maxOutputTokens: args.request.config.provider.maxOutputTokens,
1058
+ timeoutMs: args.request.config.provider.timeoutMs,
1059
+ responseMode: args.responseMode,
1060
+ jsonResponseFormat: args.request.config.provider.jsonResponseFormat
1061
+ });
1062
+ } catch (error) {
1063
+ lastError = error;
1064
+ const reason = error instanceof Error ? error.message : "unknown_error";
1065
+ if (attempt > 0 || !isRetriableReason(reason)) {
1066
+ throw error;
1067
+ }
1068
+ if (args.request.config.runtime.verbose) {
1069
+ process.stderr.write(
1070
+ `${pc.dim("sift")} retry=1 reason=${reason} delay_ms=${RETRY_DELAY_MS}
1071
+ `
1072
+ );
1073
+ }
1074
+ await delay(RETRY_DELAY_MS);
1075
+ }
1076
+ }
1077
+ throw lastError instanceof Error ? lastError : new Error("unknown_error");
1078
+ }
893
1079
  async function runSift(request) {
894
1080
  const prepared = prepareInput(request.stdin, request.config.input);
895
1081
  const { prompt, responseMode } = buildPrompt({
@@ -915,15 +1101,33 @@ async function runSift(request) {
915
1101
  process.stderr.write(`${pc.dim("sift")} heuristic=${request.policyName}
916
1102
  `);
917
1103
  }
1104
+ if (request.dryRun) {
1105
+ return buildDryRunOutput({
1106
+ request,
1107
+ providerName: provider.name,
1108
+ prompt,
1109
+ responseMode,
1110
+ prepared,
1111
+ heuristicOutput
1112
+ });
1113
+ }
918
1114
  return heuristicOutput;
919
1115
  }
1116
+ if (request.dryRun) {
1117
+ return buildDryRunOutput({
1118
+ request,
1119
+ providerName: provider.name,
1120
+ prompt,
1121
+ responseMode,
1122
+ prepared,
1123
+ heuristicOutput: null
1124
+ });
1125
+ }
920
1126
  try {
921
- const result = await provider.generate({
922
- model: request.config.provider.model,
1127
+ const result = await generateWithRetry({
1128
+ provider,
1129
+ request,
923
1130
  prompt,
924
- temperature: request.config.provider.temperature,
925
- maxOutputTokens: request.config.provider.maxOutputTokens,
926
- timeoutMs: request.config.provider.timeoutMs,
927
1131
  responseMode
928
1132
  });
929
1133
  if (looksLikeRejectedModelOutput({
@@ -1138,12 +1342,13 @@ function toNumber(value) {
1138
1342
  }
1139
1343
  function buildCliOverrides(options) {
1140
1344
  const overrides = {};
1141
- if (options.provider !== void 0 || options.model !== void 0 || options.baseUrl !== void 0 || options.apiKey !== void 0 || options.timeoutMs !== void 0) {
1345
+ if (options.provider !== void 0 || options.model !== void 0 || options.baseUrl !== void 0 || options.apiKey !== void 0 || options.jsonResponseFormat !== void 0 || options.timeoutMs !== void 0) {
1142
1346
  overrides.provider = {
1143
1347
  provider: options.provider,
1144
1348
  model: options.model,
1145
1349
  baseUrl: options.baseUrl,
1146
1350
  apiKey: options.apiKey,
1351
+ jsonResponseFormat: options.jsonResponseFormat,
1147
1352
  timeoutMs: toNumber(options.timeoutMs)
1148
1353
  };
1149
1354
  }
@@ -1167,7 +1372,13 @@ function buildCliOverrides(options) {
1167
1372
  return overrides;
1168
1373
  }
1169
1374
  function applySharedOptions(command) {
1170
- return command.option("--provider <provider>", "Provider: openai-compatible").option("--model <model>", "Model name").option("--base-url <url>", "Provider base URL").option("--api-key <key>", "Provider API key").option("--timeout-ms <ms>", "Request timeout in milliseconds").option("--format <format>", "brief | bullets | json | verdict").option("--max-capture-chars <n>", "Maximum raw child output chars kept in memory").option("--max-input-chars <n>", "Maximum input chars sent to the model").option("--head-chars <n>", "Head chars to preserve during truncation").option("--tail-chars <n>", "Tail chars to preserve during truncation").option("--strip-ansi", "Force ANSI stripping").option("--redact", "Enable standard redaction").option("--redact-strict", "Enable strict redaction").option("--raw-fallback", "Enable raw fallback text output").option("--config <path>", "Path to config file").option("--verbose", "Enable verbose stderr logging");
1375
+ return command.option("--provider <provider>", "Provider: openai-compatible").option("--model <model>", "Model name").option("--base-url <url>", "Provider base URL").option(
1376
+ "--api-key <key>",
1377
+ "Provider API key (or set SIFT_PROVIDER_API_KEY; OPENAI_API_KEY also works for api.openai.com)"
1378
+ ).option(
1379
+ "--json-response-format <mode>",
1380
+ "JSON response format mode: auto | on | off"
1381
+ ).option("--timeout-ms <ms>", "Request timeout in milliseconds").option("--format <format>", "brief | bullets | json | verdict").option("--max-capture-chars <n>", "Maximum raw child output chars kept in memory").option("--max-input-chars <n>", "Maximum input chars sent to the model").option("--head-chars <n>", "Head chars to preserve during truncation").option("--tail-chars <n>", "Tail chars to preserve during truncation").option("--strip-ansi", "Force ANSI stripping").option("--redact", "Enable standard redaction").option("--redact-strict", "Enable strict redaction").option("--raw-fallback", "Enable raw fallback text output").option("--dry-run", "Show the reduced input and prompt without calling the provider").option("--config <path>", "Path to config file").option("--verbose", "Enable verbose stderr logging");
1171
1382
  }
1172
1383
  async function executeRun(args) {
1173
1384
  const config = resolveConfig({
@@ -1181,6 +1392,7 @@ async function executeRun(args) {
1181
1392
  format: args.format,
1182
1393
  stdin,
1183
1394
  config,
1395
+ dryRun: Boolean(args.options.dryRun),
1184
1396
  policyName: args.policyName,
1185
1397
  outputContract: args.outputContract,
1186
1398
  fallbackJson: args.fallbackJson
@@ -1213,6 +1425,7 @@ async function executeExec(args) {
1213
1425
  question: args.question,
1214
1426
  format: args.format,
1215
1427
  config,
1428
+ dryRun: Boolean(args.options.dryRun),
1216
1429
  policyName: args.policyName,
1217
1430
  outputContract: args.outputContract,
1218
1431
  fallbackJson: args.fallbackJson,
@@ -1221,7 +1434,7 @@ async function executeExec(args) {
1221
1434
  }
1222
1435
  applySharedOptions(
1223
1436
  cli.command("preset <name>", "Run a named preset against piped CLI output")
1224
- ).action(async (name, options) => {
1437
+ ).usage("preset <name> [options]").example("preset test-status < test-output.txt").action(async (name, options) => {
1225
1438
  const config = resolveConfig({
1226
1439
  configPath: options.config,
1227
1440
  env: process.env,
@@ -1239,7 +1452,7 @@ applySharedOptions(
1239
1452
  });
1240
1453
  applySharedOptions(
1241
1454
  cli.command("exec [question]", "Run a command and reduce its output").allowUnknownOptions()
1242
- ).option("--shell <command>", "Execute a shell command string instead of argv mode").option("--preset <name>", "Run a named preset in exec mode").action(async (question, options) => {
1455
+ ).usage("exec [question] [options] -- <program> [args...]").example('exec "what changed?" -- git diff').example("exec --preset test-status -- pytest").example('exec --preset infra-risk --shell "terraform plan"').option("--shell <command>", "Execute a shell command string instead of argv mode").option("--preset <name>", "Run a named preset in exec mode").action(async (question, options) => {
1243
1456
  if (question === "preset") {
1244
1457
  throw new Error("Use 'sift exec --preset <name> -- <program> ...' instead.");
1245
1458
  }
@@ -1276,7 +1489,10 @@ applySharedOptions(
1276
1489
  options
1277
1490
  });
1278
1491
  });
1279
- cli.command("config <action>", "Config commands: init | show | validate").option("--path <path>", "Target config path for init").option("--config <path>", "Path to config file").option("--show-secrets", "Show secret values in config show").action((action, options) => {
1492
+ cli.command(
1493
+ "config <action>",
1494
+ "Config commands: init | show | validate (show/validate use resolved runtime config)"
1495
+ ).usage("config <init|show|validate> [options]").example("config init").example("config show").example("config validate --config ./sift.config.yaml").option("--path <path>", "Target config path for init").option("--config <path>", "Path to config file").option("--show-secrets", "Show secret values in config show").action((action, options) => {
1280
1496
  if (action === "init") {
1281
1497
  configInit(options.path);
1282
1498
  return;
@@ -1294,14 +1510,14 @@ cli.command("config <action>", "Config commands: init | show | validate").option
1294
1510
  }
1295
1511
  throw new Error(`Unknown config action: ${action}`);
1296
1512
  });
1297
- cli.command("doctor", "Validate runtime configuration").option("--config <path>", "Path to config file").action((options) => {
1513
+ cli.command("doctor", "Check local runtime config completeness").usage("doctor [options]").option("--config <path>", "Path to config file").action((options) => {
1298
1514
  const config = resolveConfig({
1299
1515
  configPath: options.config,
1300
1516
  env: process.env
1301
1517
  });
1302
1518
  process.exitCode = runDoctor(config);
1303
1519
  });
1304
- cli.command("presets <action> [name]", "Preset commands: list | show").option("--config <path>", "Path to config file").option("--internal", "Show internal preset fields in presets show").action((action, name, options) => {
1520
+ cli.command("presets <action> [name]", "Preset commands: list | show").usage("presets <list|show> [name] [options]").example("presets list").example("presets show infra-risk").option("--config <path>", "Path to config file").option("--internal", "Show internal preset fields in presets show").action((action, name, options) => {
1305
1521
  const config = resolveConfig({
1306
1522
  configPath: options.config,
1307
1523
  env: process.env
package/dist/index.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  type ProviderName = "openai-compatible";
2
2
  type OutputFormat = "brief" | "bullets" | "json" | "verdict";
3
3
  type ResponseMode = "text" | "json";
4
+ type JsonResponseFormatMode = "auto" | "on" | "off";
4
5
  type PromptPolicyName = "test-status" | "audit-critical" | "diff-summary" | "build-failure" | "log-errors" | "infra-risk";
5
6
  interface ProviderConfig {
6
7
  provider: ProviderName;
7
8
  model: string;
8
9
  baseUrl: string;
9
10
  apiKey?: string;
11
+ jsonResponseFormat: JsonResponseFormatMode;
10
12
  timeoutMs: number;
11
13
  temperature: number;
12
14
  maxOutputTokens: number;
@@ -50,6 +52,7 @@ interface GenerateInput {
50
52
  maxOutputTokens: number;
51
53
  timeoutMs: number;
52
54
  responseMode: ResponseMode;
55
+ jsonResponseFormat: JsonResponseFormatMode;
53
56
  }
54
57
  interface UsageInfo {
55
58
  inputTokens?: number;
@@ -66,6 +69,7 @@ interface RunRequest {
66
69
  format: OutputFormat;
67
70
  stdin: string;
68
71
  config: SiftConfig;
72
+ dryRun?: boolean;
69
73
  policyName?: PromptPolicyName;
70
74
  outputContract?: string;
71
75
  fallbackJson?: unknown;
@@ -103,4 +107,4 @@ interface ResolveOptions {
103
107
  }
104
108
  declare function resolveConfig(options?: ResolveOptions): SiftConfig;
105
109
 
106
- export { type ExecRequest, type GenerateInput, type GenerateResult, type InputConfig, type LLMProvider, type OutputFormat, type PartialSiftConfig, type PreparedInput, type PresetDefinition, type PromptPolicyName, type ProviderConfig, type ProviderName, type ResolveOptions, type ResponseMode, type RunRequest, type RuntimeConfig, type SiftConfig, type UsageInfo, resolveConfig, runExec, runSift };
110
+ export { type ExecRequest, type GenerateInput, type GenerateResult, type InputConfig, type JsonResponseFormatMode, type LLMProvider, type OutputFormat, type PartialSiftConfig, type PreparedInput, type PresetDefinition, type PromptPolicyName, type ProviderConfig, type ProviderName, type ResolveOptions, type ResponseMode, type RunRequest, type RuntimeConfig, type SiftConfig, type UsageInfo, resolveConfig, runExec, runSift };
package/dist/index.js CHANGED
@@ -20,6 +20,15 @@ var CAPTURE_OMITTED_MARKER = "\n...[captured output omitted]...\n";
20
20
  import pc from "picocolors";
21
21
 
22
22
  // src/providers/openaiCompatible.ts
23
+ function supportsNativeJsonResponseFormat(baseUrl, mode) {
24
+ if (mode === "off") {
25
+ return false;
26
+ }
27
+ if (mode === "on") {
28
+ return true;
29
+ }
30
+ return /^https:\/\/api\.openai\.com(?:\/|$)/i.test(baseUrl);
31
+ }
23
32
  function extractMessageText(payload) {
24
33
  const content = payload?.choices?.[0]?.message?.content;
25
34
  if (typeof content === "string") {
@@ -54,6 +63,7 @@ var OpenAICompatibleProvider = class {
54
63
  model: input.model,
55
64
  temperature: input.temperature,
56
65
  max_tokens: input.maxOutputTokens,
66
+ ...input.responseMode === "json" && supportsNativeJsonResponseFormat(this.baseUrl, input.jsonResponseFormat) ? { response_format: { type: "json_object" } } : {},
57
67
  messages: [
58
68
  {
59
69
  role: "system",
@@ -564,6 +574,7 @@ function prepareInput(raw, config) {
564
574
  }
565
575
 
566
576
  // src/core/run.ts
577
+ var RETRY_DELAY_MS = 300;
567
578
  function normalizeOutput(text, responseMode) {
568
579
  if (responseMode !== "json") {
569
580
  return text.trim();
@@ -575,6 +586,68 @@ function normalizeOutput(text, responseMode) {
575
586
  throw new Error("Provider returned invalid JSON");
576
587
  }
577
588
  }
589
+ function buildDryRunOutput(args) {
590
+ return JSON.stringify(
591
+ {
592
+ status: "dry-run",
593
+ strategy: args.heuristicOutput ? "heuristic" : "provider",
594
+ provider: {
595
+ name: args.providerName,
596
+ model: args.request.config.provider.model,
597
+ baseUrl: args.request.config.provider.baseUrl,
598
+ jsonResponseFormat: args.request.config.provider.jsonResponseFormat
599
+ },
600
+ question: args.request.question,
601
+ format: args.request.format,
602
+ responseMode: args.responseMode,
603
+ policy: args.request.policyName ?? null,
604
+ heuristicOutput: args.heuristicOutput ?? null,
605
+ input: {
606
+ originalLength: args.prepared.meta.originalLength,
607
+ finalLength: args.prepared.meta.finalLength,
608
+ redactionApplied: args.prepared.meta.redactionApplied,
609
+ truncatedApplied: args.prepared.meta.truncatedApplied,
610
+ text: args.prepared.truncated
611
+ },
612
+ prompt: args.prompt
613
+ },
614
+ null,
615
+ 2
616
+ );
617
+ }
618
+ async function delay(ms) {
619
+ await new Promise((resolve) => setTimeout(resolve, ms));
620
+ }
621
+ async function generateWithRetry(args) {
622
+ let lastError;
623
+ for (let attempt = 0; attempt < 2; attempt += 1) {
624
+ try {
625
+ return await args.provider.generate({
626
+ model: args.request.config.provider.model,
627
+ prompt: args.prompt,
628
+ temperature: args.request.config.provider.temperature,
629
+ maxOutputTokens: args.request.config.provider.maxOutputTokens,
630
+ timeoutMs: args.request.config.provider.timeoutMs,
631
+ responseMode: args.responseMode,
632
+ jsonResponseFormat: args.request.config.provider.jsonResponseFormat
633
+ });
634
+ } catch (error) {
635
+ lastError = error;
636
+ const reason = error instanceof Error ? error.message : "unknown_error";
637
+ if (attempt > 0 || !isRetriableReason(reason)) {
638
+ throw error;
639
+ }
640
+ if (args.request.config.runtime.verbose) {
641
+ process.stderr.write(
642
+ `${pc.dim("sift")} retry=1 reason=${reason} delay_ms=${RETRY_DELAY_MS}
643
+ `
644
+ );
645
+ }
646
+ await delay(RETRY_DELAY_MS);
647
+ }
648
+ }
649
+ throw lastError instanceof Error ? lastError : new Error("unknown_error");
650
+ }
578
651
  async function runSift(request) {
579
652
  const prepared = prepareInput(request.stdin, request.config.input);
580
653
  const { prompt, responseMode } = buildPrompt({
@@ -600,15 +673,33 @@ async function runSift(request) {
600
673
  process.stderr.write(`${pc.dim("sift")} heuristic=${request.policyName}
601
674
  `);
602
675
  }
676
+ if (request.dryRun) {
677
+ return buildDryRunOutput({
678
+ request,
679
+ providerName: provider.name,
680
+ prompt,
681
+ responseMode,
682
+ prepared,
683
+ heuristicOutput
684
+ });
685
+ }
603
686
  return heuristicOutput;
604
687
  }
688
+ if (request.dryRun) {
689
+ return buildDryRunOutput({
690
+ request,
691
+ providerName: provider.name,
692
+ prompt,
693
+ responseMode,
694
+ prepared,
695
+ heuristicOutput: null
696
+ });
697
+ }
605
698
  try {
606
- const result = await provider.generate({
607
- model: request.config.provider.model,
699
+ const result = await generateWithRetry({
700
+ provider,
701
+ request,
608
702
  prompt,
609
- temperature: request.config.provider.temperature,
610
- maxOutputTokens: request.config.provider.maxOutputTokens,
611
- timeoutMs: request.config.provider.timeoutMs,
612
703
  responseMode
613
704
  });
614
705
  if (looksLikeRejectedModelOutput({
@@ -797,6 +888,7 @@ var defaultConfig = {
797
888
  model: "gpt-4.1-mini",
798
889
  baseUrl: "https://api.openai.com/v1",
799
890
  apiKey: "",
891
+ jsonResponseFormat: "auto",
800
892
  timeoutMs: 2e4,
801
893
  temperature: 0.1,
802
894
  maxOutputTokens: 220
@@ -878,6 +970,52 @@ function loadRawConfig(explicitPath) {
878
970
  return YAML.parse(content) ?? {};
879
971
  }
880
972
 
973
+ // src/config/provider-api-key.ts
974
+ var OPENAI_COMPATIBLE_BASE_URL_ENV = [
975
+ { prefix: "https://api.openai.com/", envName: "OPENAI_API_KEY" },
976
+ { prefix: "https://openrouter.ai/api/", envName: "OPENROUTER_API_KEY" },
977
+ { prefix: "https://api.together.xyz/", envName: "TOGETHER_API_KEY" },
978
+ { prefix: "https://api.groq.com/openai/", envName: "GROQ_API_KEY" }
979
+ ];
980
+ var PROVIDER_API_KEY_ENV = {
981
+ anthropic: "ANTHROPIC_API_KEY",
982
+ claude: "ANTHROPIC_API_KEY",
983
+ groq: "GROQ_API_KEY",
984
+ openai: "OPENAI_API_KEY",
985
+ openrouter: "OPENROUTER_API_KEY",
986
+ together: "TOGETHER_API_KEY"
987
+ };
988
+ function normalizeBaseUrl(baseUrl) {
989
+ if (!baseUrl) {
990
+ return void 0;
991
+ }
992
+ return `${baseUrl.replace(/\/+$/, "")}/`.toLowerCase();
993
+ }
994
+ function resolveCompatibleEnvName(baseUrl) {
995
+ const normalized = normalizeBaseUrl(baseUrl);
996
+ if (!normalized) {
997
+ return void 0;
998
+ }
999
+ const match = OPENAI_COMPATIBLE_BASE_URL_ENV.find(
1000
+ (entry) => normalized.startsWith(entry.prefix)
1001
+ );
1002
+ return match?.envName;
1003
+ }
1004
+ function resolveProviderApiKey(provider, baseUrl, env) {
1005
+ if (env.SIFT_PROVIDER_API_KEY) {
1006
+ return env.SIFT_PROVIDER_API_KEY;
1007
+ }
1008
+ if (provider === "openai-compatible") {
1009
+ const envName2 = resolveCompatibleEnvName(baseUrl);
1010
+ return envName2 ? env[envName2] : void 0;
1011
+ }
1012
+ if (!provider) {
1013
+ return void 0;
1014
+ }
1015
+ const envName = PROVIDER_API_KEY_ENV[provider];
1016
+ return envName ? env[envName] : void 0;
1017
+ }
1018
+
881
1019
  // src/config/schema.ts
882
1020
  import { z } from "zod";
883
1021
  var providerNameSchema = z.enum(["openai-compatible"]);
@@ -888,6 +1026,7 @@ var outputFormatSchema = z.enum([
888
1026
  "verdict"
889
1027
  ]);
890
1028
  var responseModeSchema = z.enum(["text", "json"]);
1029
+ var jsonResponseFormatModeSchema = z.enum(["auto", "on", "off"]);
891
1030
  var promptPolicyNameSchema = z.enum([
892
1031
  "test-status",
893
1032
  "audit-critical",
@@ -901,6 +1040,7 @@ var providerConfigSchema = z.object({
901
1040
  model: z.string().min(1),
902
1041
  baseUrl: z.string().url(),
903
1042
  apiKey: z.string().optional(),
1043
+ jsonResponseFormat: jsonResponseFormatModeSchema,
904
1044
  timeoutMs: z.number().int().positive(),
905
1045
  temperature: z.number().min(0).max(2),
906
1046
  maxOutputTokens: z.number().int().positive()
@@ -954,14 +1094,25 @@ function mergeDefined(base, override) {
954
1094
  }
955
1095
  return result;
956
1096
  }
957
- function buildEnvOverrides(env) {
1097
+ function stripApiKey(overrides) {
1098
+ if (!overrides?.provider || overrides.provider.apiKey === void 0) {
1099
+ return overrides;
1100
+ }
1101
+ return {
1102
+ ...overrides,
1103
+ provider: {
1104
+ ...overrides.provider,
1105
+ apiKey: void 0
1106
+ }
1107
+ };
1108
+ }
1109
+ function buildNonCredentialEnvOverrides(env) {
958
1110
  const overrides = {};
959
- if (env.SIFT_PROVIDER || env.SIFT_MODEL || env.SIFT_BASE_URL || env.SIFT_API_KEY || env.SIFT_TIMEOUT_MS) {
1111
+ if (env.SIFT_PROVIDER || env.SIFT_MODEL || env.SIFT_BASE_URL || env.SIFT_TIMEOUT_MS) {
960
1112
  overrides.provider = {
961
1113
  provider: env.SIFT_PROVIDER,
962
1114
  model: env.SIFT_MODEL,
963
1115
  baseUrl: env.SIFT_BASE_URL,
964
- apiKey: env.SIFT_API_KEY,
965
1116
  timeoutMs: env.SIFT_TIMEOUT_MS ? Number(env.SIFT_TIMEOUT_MS) : void 0
966
1117
  };
967
1118
  }
@@ -973,12 +1124,40 @@ function buildEnvOverrides(env) {
973
1124
  }
974
1125
  return overrides;
975
1126
  }
1127
+ function buildCredentialEnvOverrides(env, context) {
1128
+ const apiKey = resolveProviderApiKey(context.provider, context.baseUrl, env);
1129
+ if (apiKey === void 0) {
1130
+ return {};
1131
+ }
1132
+ return {
1133
+ provider: {
1134
+ apiKey
1135
+ }
1136
+ };
1137
+ }
976
1138
  function resolveConfig(options = {}) {
977
1139
  const env = options.env ?? process.env;
978
1140
  const fileConfig = loadRawConfig(options.configPath);
979
- const envConfig = buildEnvOverrides(env);
1141
+ const nonCredentialEnvConfig = buildNonCredentialEnvOverrides(env);
1142
+ const contextConfig = mergeDefined(
1143
+ mergeDefined(
1144
+ mergeDefined(defaultConfig, fileConfig),
1145
+ nonCredentialEnvConfig
1146
+ ),
1147
+ stripApiKey(options.cliOverrides) ?? {}
1148
+ );
1149
+ const credentialEnvConfig = buildCredentialEnvOverrides(env, {
1150
+ provider: contextConfig.provider.provider,
1151
+ baseUrl: contextConfig.provider.baseUrl
1152
+ });
980
1153
  const merged = mergeDefined(
981
- mergeDefined(mergeDefined(defaultConfig, fileConfig), envConfig),
1154
+ mergeDefined(
1155
+ mergeDefined(
1156
+ mergeDefined(defaultConfig, fileConfig),
1157
+ nonCredentialEnvConfig
1158
+ ),
1159
+ credentialEnvConfig
1160
+ ),
982
1161
  options.cliOverrides ?? {}
983
1162
  );
984
1163
  return siftConfigSchema.parse(merged);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bilalimamoglu/sift",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-first command-output reduction layer for agents, CI, and automation.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,14 @@
37
37
  ],
38
38
  "author": "Bilal Imamoglu",
39
39
  "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/bilalimamoglu/sift.git"
43
+ },
44
+ "homepage": "https://github.com/bilalimamoglu/sift#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/bilalimamoglu/sift/issues"
47
+ },
40
48
  "dependencies": {
41
49
  "cac": "^6.7.14",
42
50
  "picocolors": "^1.1.1",