@steipete/oracle 0.4.5 → 0.5.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/README.md +11 -9
- package/dist/.DS_Store +0 -0
- package/dist/bin/oracle-cli.js +16 -48
- package/dist/scripts/agent-send.js +147 -0
- package/dist/scripts/docs-list.js +110 -0
- package/dist/scripts/git-policy.js +125 -0
- package/dist/scripts/runner.js +1378 -0
- package/dist/scripts/test-browser.js +103 -0
- package/dist/scripts/test-remote-chrome.js +68 -0
- package/dist/src/browser/actions/attachments.js +47 -16
- package/dist/src/browser/actions/promptComposer.js +67 -18
- package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
- package/dist/src/browser/chromeCookies.js +44 -6
- package/dist/src/browser/chromeLifecycle.js +166 -25
- package/dist/src/browser/config.js +25 -1
- package/dist/src/browser/constants.js +22 -3
- package/dist/src/browser/index.js +384 -22
- package/dist/src/browser/profileSync.js +141 -0
- package/dist/src/browser/prompt.js +3 -1
- package/dist/src/browser/reattach.js +59 -0
- package/dist/src/browser/sessionRunner.js +15 -1
- package/dist/src/browser/windowsCookies.js +2 -1
- package/dist/src/cli/browserConfig.js +11 -0
- package/dist/src/cli/browserDefaults.js +41 -0
- package/dist/src/cli/detach.js +2 -2
- package/dist/src/cli/dryRun.js +4 -2
- package/dist/src/cli/engine.js +2 -2
- package/dist/src/cli/help.js +2 -2
- package/dist/src/cli/options.js +2 -1
- package/dist/src/cli/runOptions.js +1 -1
- package/dist/src/cli/sessionDisplay.js +102 -104
- package/dist/src/cli/sessionRunner.js +39 -6
- package/dist/src/cli/sessionTable.js +88 -0
- package/dist/src/cli/tui/index.js +19 -89
- package/dist/src/heartbeat.js +2 -2
- package/dist/src/oracle/background.js +10 -2
- package/dist/src/oracle/client.js +107 -0
- package/dist/src/oracle/config.js +10 -2
- package/dist/src/oracle/errors.js +24 -4
- package/dist/src/oracle/modelResolver.js +144 -0
- package/dist/src/oracle/oscProgress.js +1 -1
- package/dist/src/oracle/run.js +83 -34
- package/dist/src/oracle/runUtils.js +12 -8
- package/dist/src/remote/server.js +214 -23
- package/dist/src/sessionManager.js +5 -2
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +14 -14
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="MIT License"></a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
|
-
Oracle bundles your prompt and files so another AI can answer with real context. It speaks GPT-5.1 Pro (default), GPT-5.1 Codex (API-only), GPT-5.1, Gemini 3 Pro, Claude Sonnet 4.5, Claude Opus 4.1, and more—and it can ask one or multiple models in a single run. Browser automation
|
|
14
|
+
Oracle bundles your prompt and files so another AI can answer with real context. It speaks GPT-5.1 Pro (default), GPT-5.1 Codex (API-only), GPT-5.1, Gemini 3 Pro, Claude Sonnet 4.5, Claude Opus 4.1, and more—and it can ask one or multiple models in a single run. Browser automation is available; API remains the most reliable path, and `--copy` is an easy manual fallback.
|
|
15
15
|
|
|
16
16
|
## Quick start
|
|
17
17
|
|
|
@@ -30,7 +30,7 @@ npx @steipete/oracle -p "Cross-check the data layer assumptions" --models gpt-5.
|
|
|
30
30
|
# Preview without spending tokens
|
|
31
31
|
npx @steipete/oracle --dry-run summary -p "Check release notes" --file docs/release-notes.md
|
|
32
32
|
|
|
33
|
-
#
|
|
33
|
+
# Browser run (no API key, will open ChatGPT)
|
|
34
34
|
npx @steipete/oracle --engine browser -p "Walk through the UI smoke test" --file "src/**/*.ts"
|
|
35
35
|
|
|
36
36
|
# Sessions (list and replay)
|
|
@@ -41,14 +41,14 @@ npx @steipete/oracle session <id> --render
|
|
|
41
41
|
npx @steipete/oracle
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
Engine auto-picks API when `OPENAI_API_KEY` is set, otherwise browser; browser is stable on macOS
|
|
44
|
+
Engine auto-picks API when `OPENAI_API_KEY` is set, otherwise browser; browser is stable on macOS and works on Linux and Windows. On Linux pass `--browser-chrome-path/--browser-cookie-path` if detection fails; on Windows prefer `--browser-manual-login` or inline cookies if decryption is blocked.
|
|
45
45
|
|
|
46
46
|
## Integration
|
|
47
47
|
|
|
48
48
|
**CLI**
|
|
49
49
|
- API mode expects API keys in your environment: `OPENAI_API_KEY` (GPT-5.x), `GEMINI_API_KEY` (Gemini 3 Pro), `ANTHROPIC_API_KEY` (Claude Sonnet 4.5 / Opus 4.1).
|
|
50
50
|
- Prefer API mode or `--copy` + manual paste; browser automation is experimental.
|
|
51
|
-
- Browser support: stable on macOS; works on Linux
|
|
51
|
+
- Browser support: stable on macOS; works on Linux (add `--browser-chrome-path/--browser-cookie-path` when needed) and Windows (manual-login or inline cookies recommended when app-bound cookies block decryption).
|
|
52
52
|
- Remote browser service: `oracle serve` on a signed-in host; clients use `--remote-host/--remote-token`.
|
|
53
53
|
- AGENTS.md/CLAUDE.md:
|
|
54
54
|
```
|
|
@@ -78,7 +78,7 @@ npx -y @steipete/oracle oracle-mcp
|
|
|
78
78
|
## Highlights
|
|
79
79
|
|
|
80
80
|
- Bundle once, reuse anywhere (API or experimental browser).
|
|
81
|
-
- Multi-model API runs with aggregated cost/usage.
|
|
81
|
+
- Multi-model API runs with aggregated cost/usage, including OpenRouter IDs alongside first-party models.
|
|
82
82
|
- Render/copy bundles for manual paste into ChatGPT when automation is blocked.
|
|
83
83
|
- GPT‑5 Pro API runs detach by default; reattach via `oracle session <id>` / `oracle status` or block with `--wait`.
|
|
84
84
|
- Azure endpoints supported via `--azure-endpoint/--azure-deployment/--azure-api-version` or `AZURE_OPENAI_*` envs.
|
|
@@ -93,10 +93,11 @@ npx -y @steipete/oracle oracle-mcp
|
|
|
93
93
|
| `-p, --prompt <text>` | Required prompt. |
|
|
94
94
|
| `-f, --file <paths...>` | Attach files/dirs (globs + `!` excludes). |
|
|
95
95
|
| `-e, --engine <api\|browser>` | Choose API or browser (browser is experimental). |
|
|
96
|
-
| `-m, --model <name>` | `gpt-5.1-pro`
|
|
97
|
-
| `--models <list>` | Comma-separated API models for multi-model runs. |
|
|
98
|
-
| `--base-url <url>` | Point API runs at LiteLLM/Azure/etc. |
|
|
96
|
+
| `-m, --model <name>` | Built-ins (`gpt-5.1-pro` default, `gpt-5-pro`, `gpt-5.1`, `gpt-5.1-codex`, `gemini-3-pro`, `claude-4.5-sonnet`, `claude-4.1-opus`) plus any OpenRouter id (e.g., `minimax/minimax-m2`, `openai/gpt-4o-mini`). |
|
|
97
|
+
| `--models <list>` | Comma-separated API models (mix built-ins and OpenRouter ids) for multi-model runs. |
|
|
98
|
+
| `--base-url <url>` | Point API runs at LiteLLM/Azure/OpenRouter/etc. |
|
|
99
99
|
| `--chatgpt-url <url>` | Target a ChatGPT workspace/folder (browser). |
|
|
100
|
+
| `--browser-port <port>` | Pin the Chrome DevTools port (WSL/Windows firewall helper). |
|
|
100
101
|
| `--browser-inline-cookies[(-file)] <payload|path>` | Supply cookies without Chrome/Keychain (browser). |
|
|
101
102
|
| `--browser-timeout`, `--browser-input-timeout` | Control overall/browser input timeouts (supports h/m/s/ms). |
|
|
102
103
|
| `--render`, `--copy` | Print and/or copy the assembled markdown bundle. |
|
|
@@ -152,8 +153,9 @@ oracle status --clear --hours 168
|
|
|
152
153
|
## More docs
|
|
153
154
|
- Browser mode & forks: [docs/browser-mode.md](docs/browser-mode.md) (includes `oracle serve` remote service), [docs/chromium-forks.md](docs/chromium-forks.md), [docs/linux.md](docs/linux.md)
|
|
154
155
|
- MCP: [docs/mcp.md](docs/mcp.md)
|
|
155
|
-
- OpenAI/Azure endpoints: [docs/openai-endpoints.md](docs/openai-endpoints.md)
|
|
156
|
+
- OpenAI/Azure/OpenRouter endpoints: [docs/openai-endpoints.md](docs/openai-endpoints.md), [docs/openrouter.md](docs/openrouter.md)
|
|
156
157
|
- Manual smokes: [docs/manual-tests.md](docs/manual-tests.md)
|
|
158
|
+
- Testing: [docs/testing.md](docs/testing.md)
|
|
157
159
|
|
|
158
160
|
If you’re looking for an even more powerful context-management tool, check out https://repoprompt.com
|
|
159
161
|
Name inspired by: https://ampcode.com/news/oracle
|
package/dist/.DS_Store
CHANGED
|
Binary file
|
package/dist/bin/oracle-cli.js
CHANGED
|
@@ -15,7 +15,8 @@ import { shouldRequirePrompt } from '../src/cli/promptRequirement.js';
|
|
|
15
15
|
import chalk from 'chalk';
|
|
16
16
|
import { sessionStore, pruneOldSessions } from '../src/sessionStore.js';
|
|
17
17
|
import { DEFAULT_MODEL, MODEL_CONFIGS, readFiles, estimateRequestTokens, buildRequestBody } from '../src/oracle.js';
|
|
18
|
-
import {
|
|
18
|
+
import { isKnownModel } from '../src/oracle/modelResolver.js';
|
|
19
|
+
import { CHATGPT_URL } from '../src/browserMode.js';
|
|
19
20
|
import { createRemoteBrowserExecutor } from '../src/remote/client.js';
|
|
20
21
|
import { applyHelpStyling } from '../src/cli/help.js';
|
|
21
22
|
import { collectPaths, collectModelList, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, mergePathLikeOptions, } from '../src/cli/options.js';
|
|
@@ -41,6 +42,7 @@ import { runDryRunSummary, runBrowserPreview } from '../src/cli/dryRun.js';
|
|
|
41
42
|
import { launchTui } from '../src/cli/tui/index.js';
|
|
42
43
|
import { resolveNotificationSettings, deriveNotificationSettingsFromMetadata, } from '../src/cli/notifier.js';
|
|
43
44
|
import { loadUserConfig } from '../src/config.js';
|
|
45
|
+
import { applyBrowserDefaultsFromConfig } from '../src/cli/browserDefaults.js';
|
|
44
46
|
import { shouldBlockDuplicatePrompt } from '../src/cli/duplicatePromptGuard.js';
|
|
45
47
|
const VERSION = getCliVersion();
|
|
46
48
|
const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
|
|
@@ -160,10 +162,14 @@ program
|
|
|
160
162
|
.addOption(new Option('--browser-url <url>', `Alias for --chatgpt-url (default ${CHATGPT_URL}).`).hideHelp())
|
|
161
163
|
.addOption(new Option('--browser-timeout <ms|s|m>', 'Maximum time to wait for an answer (default 1200s / 20m).').hideHelp())
|
|
162
164
|
.addOption(new Option('--browser-input-timeout <ms|s|m>', 'Maximum time to wait for the prompt textarea (default 30s).').hideHelp())
|
|
165
|
+
.addOption(new Option('--browser-port <port>', 'Use a fixed Chrome DevTools port (helpful on WSL firewalls).')
|
|
166
|
+
.argParser(parseIntOption))
|
|
167
|
+
.addOption(new Option('--browser-debug-port <port>', '(alias) Use a fixed Chrome DevTools port.').argParser(parseIntOption).hideHelp())
|
|
163
168
|
.addOption(new Option('--browser-cookie-names <names>', 'Comma-separated cookie allowlist for sync.').hideHelp())
|
|
164
169
|
.addOption(new Option('--browser-inline-cookies <jsonOrBase64>', 'Inline cookies payload (JSON array or base64-encoded JSON).').hideHelp())
|
|
165
170
|
.addOption(new Option('--browser-inline-cookies-file <path>', 'Load inline cookies from file (JSON or base64 JSON).').hideHelp())
|
|
166
171
|
.addOption(new Option('--browser-no-cookie-sync', 'Skip copying cookies from Chrome.').hideHelp())
|
|
172
|
+
.addOption(new Option('--browser-manual-login', 'Skip cookie copy; reuse a persistent automation profile and wait for manual ChatGPT login.').hideHelp())
|
|
167
173
|
.addOption(new Option('--browser-headless', 'Launch Chrome in headless mode.').hideHelp())
|
|
168
174
|
.addOption(new Option('--browser-hide-window', 'Hide the Chrome window after launch (macOS headful only).').hideHelp())
|
|
169
175
|
.addOption(new Option('--browser-keep-browser', 'Keep Chrome running after completion.').hideHelp())
|
|
@@ -363,7 +369,7 @@ function getBrowserConfigFromMetadata(metadata) {
|
|
|
363
369
|
async function runRootCommand(options) {
|
|
364
370
|
if (process.env.ORACLE_FORCE_TUI === '1') {
|
|
365
371
|
await sessionStore.ensureStorage();
|
|
366
|
-
await launchTui({ version: VERSION });
|
|
372
|
+
await launchTui({ version: VERSION, printIntro: false });
|
|
367
373
|
return;
|
|
368
374
|
}
|
|
369
375
|
const userConfig = (await loadUserConfig()).config;
|
|
@@ -417,7 +423,7 @@ async function runRootCommand(options) {
|
|
|
417
423
|
}
|
|
418
424
|
if (userCliArgs.length === 0) {
|
|
419
425
|
if (tuiEnabled()) {
|
|
420
|
-
await launchTui({ version: VERSION });
|
|
426
|
+
await launchTui({ version: VERSION, printIntro: false });
|
|
421
427
|
return;
|
|
422
428
|
}
|
|
423
429
|
console.log(chalk.yellow('No prompt or subcommand supplied. See `oracle --help` for usage.'));
|
|
@@ -528,7 +534,9 @@ async function runRootCommand(options) {
|
|
|
528
534
|
const resolvedModel = normalizedMultiModels[0] ?? (isGemini ? resolveApiModel(cliModelArg) : resolvedModelCandidate);
|
|
529
535
|
const effectiveModelId = resolvedModel.startsWith('gemini')
|
|
530
536
|
? resolveGeminiModelId(resolvedModel)
|
|
531
|
-
:
|
|
537
|
+
: isKnownModel(resolvedModel)
|
|
538
|
+
? MODEL_CONFIGS[resolvedModel].apiModel ?? resolvedModel
|
|
539
|
+
: resolvedModel;
|
|
532
540
|
const resolvedBaseUrl = normalizeBaseUrl(options.baseUrl ?? (isClaude ? process.env.ANTHROPIC_BASE_URL : process.env.OPENAI_BASE_URL));
|
|
533
541
|
const { models: _rawModels, ...optionsWithoutModels } = options;
|
|
534
542
|
const resolvedOptions = { ...optionsWithoutModels, model: resolvedModel };
|
|
@@ -566,7 +574,7 @@ async function runRootCommand(options) {
|
|
|
566
574
|
throw new Error('Prompt is required when using --render-markdown or --copy-markdown.');
|
|
567
575
|
}
|
|
568
576
|
const bundle = await buildMarkdownBundle({ prompt: options.prompt, file: options.file, system: options.system }, { cwd: process.cwd() });
|
|
569
|
-
const modelConfig = MODEL_CONFIGS[resolvedModel];
|
|
577
|
+
const modelConfig = isKnownModel(resolvedModel) ? MODEL_CONFIGS[resolvedModel] : MODEL_CONFIGS['gpt-5.1'];
|
|
570
578
|
const requestBody = buildRequestBody({
|
|
571
579
|
modelConfig,
|
|
572
580
|
systemPrompt: bundle.systemPrompt,
|
|
@@ -658,7 +666,8 @@ async function runRootCommand(options) {
|
|
|
658
666
|
if (options.file && options.file.length > 0) {
|
|
659
667
|
await readFiles(options.file, { cwd: process.cwd() });
|
|
660
668
|
}
|
|
661
|
-
|
|
669
|
+
const getSource = (key) => program.getOptionValueSource?.(key) ?? undefined;
|
|
670
|
+
applyBrowserDefaultsFromConfig(options, userConfig, getSource);
|
|
662
671
|
const notifications = resolveNotificationSettings({
|
|
663
672
|
cliNotify: options.notify,
|
|
664
673
|
cliNotifySound: options.notifySound,
|
|
@@ -878,6 +887,7 @@ function printDebugHelp(cliName) {
|
|
|
878
887
|
['--browser-timeout <ms|s|m>', 'Cap total wait time for the assistant response.'],
|
|
879
888
|
['--browser-input-timeout <ms|s|m>', 'Cap how long we wait for the composer textarea.'],
|
|
880
889
|
['--browser-no-cookie-sync', 'Skip copying cookies from your main profile.'],
|
|
890
|
+
['--browser-manual-login', 'Skip cookie copy; reuse a persistent automation profile and log in manually.'],
|
|
881
891
|
['--browser-headless', 'Launch Chrome in headless mode.'],
|
|
882
892
|
['--browser-hide-window', 'Hide the Chrome window (macOS headful only).'],
|
|
883
893
|
['--browser-keep-browser', 'Leave Chrome running after completion.'],
|
|
@@ -899,48 +909,6 @@ function resolveWaitFlag({ waitFlag, noWaitFlag, model, engine, }) {
|
|
|
899
909
|
return false;
|
|
900
910
|
return defaultWaitPreference(model, engine);
|
|
901
911
|
}
|
|
902
|
-
function applyBrowserDefaultsFromConfig(options, config) {
|
|
903
|
-
const browser = config.browser;
|
|
904
|
-
if (!browser)
|
|
905
|
-
return;
|
|
906
|
-
const source = (key) => program.getOptionValueSource?.(key);
|
|
907
|
-
const configuredChatgptUrl = browser.chatgptUrl ?? browser.url;
|
|
908
|
-
if (source('chatgptUrl') === 'default' && configuredChatgptUrl !== undefined) {
|
|
909
|
-
try {
|
|
910
|
-
options.chatgptUrl = normalizeChatgptUrl(configuredChatgptUrl ?? '', CHATGPT_URL);
|
|
911
|
-
}
|
|
912
|
-
catch (error) {
|
|
913
|
-
throw error instanceof Error ? error : new Error(String(error));
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
if (source('browserChromeProfile') === 'default' && browser.chromeProfile !== undefined) {
|
|
917
|
-
options.browserChromeProfile = browser.chromeProfile ?? undefined;
|
|
918
|
-
}
|
|
919
|
-
if (source('browserChromePath') === 'default' && browser.chromePath !== undefined) {
|
|
920
|
-
options.browserChromePath = browser.chromePath ?? undefined;
|
|
921
|
-
}
|
|
922
|
-
if (source('browserCookiePath') === 'default' && browser.chromeCookiePath !== undefined) {
|
|
923
|
-
options.browserCookiePath = browser.chromeCookiePath ?? undefined;
|
|
924
|
-
}
|
|
925
|
-
if (source('browserUrl') === 'default' && browser.url !== undefined) {
|
|
926
|
-
options.browserUrl = browser.url;
|
|
927
|
-
}
|
|
928
|
-
if (source('browserTimeout') === 'default' && typeof browser.timeoutMs === 'number') {
|
|
929
|
-
options.browserTimeout = String(browser.timeoutMs);
|
|
930
|
-
}
|
|
931
|
-
if (source('browserInputTimeout') === 'default' && typeof browser.inputTimeoutMs === 'number') {
|
|
932
|
-
options.browserInputTimeout = String(browser.inputTimeoutMs);
|
|
933
|
-
}
|
|
934
|
-
if (source('browserHeadless') === 'default' && browser.headless !== undefined) {
|
|
935
|
-
options.browserHeadless = browser.headless;
|
|
936
|
-
}
|
|
937
|
-
if (source('browserHideWindow') === 'default' && browser.hideWindow !== undefined) {
|
|
938
|
-
options.browserHideWindow = browser.hideWindow;
|
|
939
|
-
}
|
|
940
|
-
if (source('browserKeepBrowser') === 'default' && browser.keepBrowser !== undefined) {
|
|
941
|
-
options.browserKeepBrowser = browser.keepBrowser;
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
912
|
program.action(async function () {
|
|
945
913
|
const options = this.optsWithGlobals();
|
|
946
914
|
await runRootCommand(options);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @ts-nocheck
|
|
3
|
+
/**
|
|
4
|
+
* Lightweight helper to send a one-off message to a tmux-based agent session.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* bun scripts/agent-send.ts --session claude-haiku -- "/model"
|
|
8
|
+
*
|
|
9
|
+
* Options:
|
|
10
|
+
* --session NAME Target tmux session (or session:window.pane)
|
|
11
|
+
* --entry single|double|none How many Enter keys to send (default single)
|
|
12
|
+
* --escape Send ESC before typing (to interrupt/resume)
|
|
13
|
+
* --wait-ms N Extra wait (ms) after typing before Enter
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { sleepSync } from 'bun';
|
|
17
|
+
function usage(message) {
|
|
18
|
+
if (message) {
|
|
19
|
+
console.error(`Error: ${message}`);
|
|
20
|
+
}
|
|
21
|
+
console.error(`\
|
|
22
|
+
Usage: bun scripts/agent-send.ts --session <name[:window[.pane]]> [--entry single|double|none] [--escape] [--wait-ms N] -- "<message>"
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
bun scripts/agent-send.ts --session claude-haiku -- "/model"
|
|
26
|
+
bun scripts/agent-send.ts --session ma-worker-1 --escape --entry double -- "Continue and focus on API routes"
|
|
27
|
+
`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
function parseArgs(argv) {
|
|
31
|
+
let session;
|
|
32
|
+
let entry = 'single';
|
|
33
|
+
let shouldEscape = false;
|
|
34
|
+
let waitMs = 400;
|
|
35
|
+
const literalSeparator = argv.indexOf('--');
|
|
36
|
+
const optionPart = literalSeparator === -1 ? argv : argv.slice(0, literalSeparator);
|
|
37
|
+
const literalPart = literalSeparator === -1 ? [] : argv.slice(literalSeparator + 1);
|
|
38
|
+
for (let i = 0; i < optionPart.length; i += 1) {
|
|
39
|
+
const token = optionPart[i];
|
|
40
|
+
if (!token.startsWith('--')) {
|
|
41
|
+
usage(`Unexpected argument: ${token}`);
|
|
42
|
+
}
|
|
43
|
+
const key = token.slice(2);
|
|
44
|
+
switch (key) {
|
|
45
|
+
case 'session': {
|
|
46
|
+
const value = optionPart[i + 1];
|
|
47
|
+
if (!value)
|
|
48
|
+
usage('--session requires a value');
|
|
49
|
+
session = value;
|
|
50
|
+
i += 1;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
case 'entry': {
|
|
54
|
+
const value = optionPart[i + 1];
|
|
55
|
+
if (value !== 'single' && value !== 'double' && value !== 'none') {
|
|
56
|
+
usage(`Unknown entry mode: ${value}`);
|
|
57
|
+
}
|
|
58
|
+
entry = value;
|
|
59
|
+
i += 1;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case 'escape': {
|
|
63
|
+
shouldEscape = true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case 'wait-ms': {
|
|
67
|
+
const value = optionPart[i + 1];
|
|
68
|
+
if (!value || Number.isNaN(Number.parseInt(value, 10))) {
|
|
69
|
+
usage('--wait-ms requires an integer value');
|
|
70
|
+
}
|
|
71
|
+
waitMs = Number.parseInt(value, 10);
|
|
72
|
+
i += 1;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
default:
|
|
76
|
+
usage(`Unknown option: --${key}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const message = literalPart.join(' ').trim();
|
|
80
|
+
if (!session)
|
|
81
|
+
usage('Missing --session');
|
|
82
|
+
if (!message)
|
|
83
|
+
usage('Missing message (provide text after -- separator)');
|
|
84
|
+
return { session, entry, escape: shouldEscape, waitMs, message };
|
|
85
|
+
}
|
|
86
|
+
function runTmux(args, allowFailure = false) {
|
|
87
|
+
const result = spawnSync('tmux', args, { encoding: 'utf8' });
|
|
88
|
+
if (result.error) {
|
|
89
|
+
if (allowFailure)
|
|
90
|
+
return '';
|
|
91
|
+
throw result.error;
|
|
92
|
+
}
|
|
93
|
+
if (result.status !== 0) {
|
|
94
|
+
if (allowFailure)
|
|
95
|
+
return result.stderr?.trim() ?? '';
|
|
96
|
+
throw new Error(`tmux ${args.join(' ')} failed: ${result.stderr?.trim()}`);
|
|
97
|
+
}
|
|
98
|
+
return result.stdout?.trimEnd() ?? '';
|
|
99
|
+
}
|
|
100
|
+
function ensureSession(target) {
|
|
101
|
+
const session = target.split(':')[0] ?? target;
|
|
102
|
+
const result = spawnSync('tmux', ['has-session', '-t', session]);
|
|
103
|
+
if (result.status !== 0) {
|
|
104
|
+
usage(`tmux session '${session}' not found. Start it first (e.g., tmux new-session -s ${session} ...)`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function sendMessage(options) {
|
|
108
|
+
ensureSession(options.session);
|
|
109
|
+
if (options.escape) {
|
|
110
|
+
runTmux(['send-keys', '-t', options.session, 'Escape'], true);
|
|
111
|
+
sleepSync(200);
|
|
112
|
+
}
|
|
113
|
+
// Clear existing prompt
|
|
114
|
+
runTmux(['send-keys', '-t', options.session, 'Escape'], true);
|
|
115
|
+
sleepSync(120);
|
|
116
|
+
runTmux(['send-keys', '-t', options.session, 'C-u'], true);
|
|
117
|
+
sleepSync(120);
|
|
118
|
+
// Type the message
|
|
119
|
+
runTmux(['send-keys', '-t', options.session, '-l', options.message], true);
|
|
120
|
+
sleepSync(Math.max(120, options.waitMs));
|
|
121
|
+
// Send Enter(s)
|
|
122
|
+
const pressEnter = () => runTmux(['send-keys', '-t', options.session, 'C-m'], true);
|
|
123
|
+
switch (options.entry) {
|
|
124
|
+
case 'single':
|
|
125
|
+
pressEnter();
|
|
126
|
+
break;
|
|
127
|
+
case 'double':
|
|
128
|
+
pressEnter();
|
|
129
|
+
sleepSync(200);
|
|
130
|
+
pressEnter();
|
|
131
|
+
break;
|
|
132
|
+
case 'none':
|
|
133
|
+
break;
|
|
134
|
+
default:
|
|
135
|
+
usage(`Unsupported entry mode: ${options.entry}`);
|
|
136
|
+
}
|
|
137
|
+
sleepSync(600);
|
|
138
|
+
const tail = runTmux(['capture-pane', '-pt', options.session, '-S', '-6'], true);
|
|
139
|
+
console.log(tail);
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const options = parseArgs(process.argv.slice(2));
|
|
143
|
+
sendMessage(options);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
usage(error instanceof Error ? error.message : String(error));
|
|
147
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, relative } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { compact } from 'es-toolkit';
|
|
6
|
+
const docsListFile = fileURLToPath(import.meta.url);
|
|
7
|
+
const docsListDir = dirname(docsListFile);
|
|
8
|
+
const DOCS_DIR = join(docsListDir, '..', 'docs');
|
|
9
|
+
const EXCLUDED_DIRS = new Set(['archive', 'research']);
|
|
10
|
+
function walkMarkdownFiles(dir, base = dir) {
|
|
11
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
12
|
+
const files = [];
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (entry.name.startsWith('.')) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const fullPath = join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
if (EXCLUDED_DIRS.has(entry.name)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
files.push(...walkMarkdownFiles(fullPath, base));
|
|
23
|
+
}
|
|
24
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
25
|
+
files.push(relative(base, fullPath));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
29
|
+
}
|
|
30
|
+
function extractMetadata(fullPath) {
|
|
31
|
+
const content = readFileSync(fullPath, 'utf8');
|
|
32
|
+
if (!content.startsWith('---')) {
|
|
33
|
+
return { summary: null, readWhen: [], error: 'missing front matter' };
|
|
34
|
+
}
|
|
35
|
+
const endIndex = content.indexOf('\n---', 3);
|
|
36
|
+
if (endIndex === -1) {
|
|
37
|
+
return { summary: null, readWhen: [], error: 'unterminated front matter' };
|
|
38
|
+
}
|
|
39
|
+
const frontMatter = content.slice(3, endIndex).trim();
|
|
40
|
+
const lines = frontMatter.split('\n');
|
|
41
|
+
let summaryLine = null;
|
|
42
|
+
const readWhen = [];
|
|
43
|
+
let collectingField = null;
|
|
44
|
+
for (const rawLine of lines) {
|
|
45
|
+
const line = rawLine.trim();
|
|
46
|
+
if (line.startsWith('summary:')) {
|
|
47
|
+
summaryLine = line;
|
|
48
|
+
collectingField = null;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (line.startsWith('read_when:')) {
|
|
52
|
+
collectingField = 'read_when';
|
|
53
|
+
const inline = line.slice('read_when:'.length).trim();
|
|
54
|
+
if (inline.startsWith('[') && inline.endsWith(']')) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(inline.replace(/'/g, '"'));
|
|
57
|
+
if (Array.isArray(parsed)) {
|
|
58
|
+
readWhen.push(...compact(parsed.map((item) => String(item).trim())));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// ignore malformed inline arrays
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (collectingField === 'read_when') {
|
|
68
|
+
if (line.startsWith('- ')) {
|
|
69
|
+
const hint = line.slice(2).trim();
|
|
70
|
+
if (hint) {
|
|
71
|
+
readWhen.push(hint);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (line === '') {
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
collectingField = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!summaryLine) {
|
|
82
|
+
return { summary: null, readWhen, error: 'summary key missing' };
|
|
83
|
+
}
|
|
84
|
+
const summaryValue = summaryLine.slice('summary:'.length).trim();
|
|
85
|
+
const normalized = summaryValue
|
|
86
|
+
.replace(/^['"]|['"]$/g, '')
|
|
87
|
+
.replace(/\s+/g, ' ')
|
|
88
|
+
.trim();
|
|
89
|
+
if (!normalized) {
|
|
90
|
+
return { summary: null, readWhen, error: 'summary is empty' };
|
|
91
|
+
}
|
|
92
|
+
return { summary: normalized, readWhen };
|
|
93
|
+
}
|
|
94
|
+
console.log('Listing all markdown files in docs folder:');
|
|
95
|
+
const markdownFiles = walkMarkdownFiles(DOCS_DIR);
|
|
96
|
+
for (const relativePath of markdownFiles) {
|
|
97
|
+
const fullPath = join(DOCS_DIR, relativePath);
|
|
98
|
+
const { summary, readWhen, error } = extractMetadata(fullPath);
|
|
99
|
+
if (summary) {
|
|
100
|
+
console.log(`${relativePath} - ${summary}`);
|
|
101
|
+
if (readWhen.length > 0) {
|
|
102
|
+
console.log(` Read when: ${readWhen.join('; ')}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const reason = error ? ` - [${error}]` : '';
|
|
107
|
+
console.log(`${relativePath}${reason}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
console.log('\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.');
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
const COMMIT_HELPER_SUBCOMMANDS = new Set(['add', 'commit']);
|
|
3
|
+
const GUARDED_SUBCOMMANDS = new Set(['push', 'pull', 'merge', 'rebase', 'cherry-pick']);
|
|
4
|
+
const DESTRUCTIVE_SUBCOMMANDS = new Set([
|
|
5
|
+
'reset',
|
|
6
|
+
'checkout',
|
|
7
|
+
'clean',
|
|
8
|
+
'restore',
|
|
9
|
+
'switch',
|
|
10
|
+
'stash',
|
|
11
|
+
'branch',
|
|
12
|
+
'filter-branch',
|
|
13
|
+
'fast-import',
|
|
14
|
+
]);
|
|
15
|
+
export function extractGitInvocation(commandArgs) {
|
|
16
|
+
for (const [index, token] of commandArgs.entries()) {
|
|
17
|
+
if (token === 'git' || token.endsWith('/git')) {
|
|
18
|
+
return { index, argv: commandArgs.slice(index) };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
export function findGitSubcommand(commandArgs) {
|
|
24
|
+
if (commandArgs.length <= 1) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const optionsWithValue = new Set(['-C', '--git-dir', '--work-tree', '-c']);
|
|
28
|
+
let index = 1;
|
|
29
|
+
while (index < commandArgs.length) {
|
|
30
|
+
const token = commandArgs[index];
|
|
31
|
+
if (token === undefined) {
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
if (token === '--') {
|
|
35
|
+
const next = commandArgs[index + 1];
|
|
36
|
+
return next ? { name: next, index: index + 1 } : null;
|
|
37
|
+
}
|
|
38
|
+
if (!token.startsWith('-')) {
|
|
39
|
+
return { name: token, index };
|
|
40
|
+
}
|
|
41
|
+
if (token.includes('=')) {
|
|
42
|
+
index += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (optionsWithValue.has(token)) {
|
|
46
|
+
index += 2;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
index += 1;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
export function determineGitWorkdir(baseDir, gitArgs, command) {
|
|
54
|
+
let workDir = baseDir;
|
|
55
|
+
const limit = command ? command.index : gitArgs.length;
|
|
56
|
+
let index = 1;
|
|
57
|
+
while (index < limit) {
|
|
58
|
+
const token = gitArgs[index];
|
|
59
|
+
if (token === undefined) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (token === '-C') {
|
|
63
|
+
const next = gitArgs[index + 1];
|
|
64
|
+
if (next) {
|
|
65
|
+
workDir = resolve(workDir, next);
|
|
66
|
+
}
|
|
67
|
+
index += 2;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (token.startsWith('-C')) {
|
|
71
|
+
const pathSegment = token.slice(2);
|
|
72
|
+
if (pathSegment.length > 0) {
|
|
73
|
+
workDir = resolve(workDir, pathSegment);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
index += 1;
|
|
77
|
+
}
|
|
78
|
+
return workDir;
|
|
79
|
+
}
|
|
80
|
+
export function analyzeGitExecution(commandArgs, workspaceDir) {
|
|
81
|
+
const invocation = extractGitInvocation(commandArgs);
|
|
82
|
+
const command = invocation ? findGitSubcommand(invocation.argv) : null;
|
|
83
|
+
const workDir = invocation ? determineGitWorkdir(workspaceDir, invocation.argv, command) : workspaceDir;
|
|
84
|
+
return {
|
|
85
|
+
invocation,
|
|
86
|
+
command,
|
|
87
|
+
subcommand: command?.name ?? null,
|
|
88
|
+
workDir,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export function requiresCommitHelper(subcommand) {
|
|
92
|
+
if (!subcommand) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return COMMIT_HELPER_SUBCOMMANDS.has(subcommand);
|
|
96
|
+
}
|
|
97
|
+
export function requiresExplicitGitConsent(subcommand) {
|
|
98
|
+
if (!subcommand) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return GUARDED_SUBCOMMANDS.has(subcommand);
|
|
102
|
+
}
|
|
103
|
+
export function isDestructiveGitSubcommand(command, gitArgv) {
|
|
104
|
+
if (!command) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const subcommand = command.name;
|
|
108
|
+
if (DESTRUCTIVE_SUBCOMMANDS.has(subcommand)) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (subcommand === 'bisect') {
|
|
112
|
+
const action = gitArgv[command.index + 1] ?? '';
|
|
113
|
+
return action === 'reset';
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
export function evaluateGitPolicies(context) {
|
|
118
|
+
const invocationArgv = context.invocation?.argv;
|
|
119
|
+
const normalizedArgv = Array.isArray(invocationArgv) ? invocationArgv : [];
|
|
120
|
+
return {
|
|
121
|
+
requiresCommitHelper: requiresCommitHelper(context.subcommand),
|
|
122
|
+
requiresExplicitConsent: requiresExplicitGitConsent(context.subcommand),
|
|
123
|
+
isDestructive: isDestructiveGitSubcommand(context.command, normalizedArgv),
|
|
124
|
+
};
|
|
125
|
+
}
|