diff --git a/CLAUDE.md b/CLAUDE.md index 461d7a5..ae4cc81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,8 +29,10 @@ Follow these rules when editing code in this project. `sibyl` is a CLI web search/crawl tool for AI Agents (`bin: sibyl` → `dist/cli.js`) with a filesystem-based plugin system. Key modules: -- `src/cli.ts` — entry point. Ensures dirs + config exist, loads plugins, builds a `PluginContext` (`buildPluginContext`), and dispatches commands (`search`, `fetch`, `ask`, `help`/`--help`/`-h`, `version`/`--version`). `search`, `fetch`, and `ask` are wired up via the async `handleSearch`/`handleFetch`/`handleAsk` helpers (awaited by `main`), each passing the context as the last arg to the selected plugin's `fn`. The `fetch` command prints the fetch plugin's output directly — the CLI doesn't dispatch a separate `parse` step, but a fetch plugin may itself run the configured parse plugin via `context.configuredPlugins.parse` (`builtin-brightdata-fetch`, `builtin-crawl4ai-fetch`, and `builtin-alterlab-fetch` do; `builtin-firecrawl-fetch` does only in its raw-HTML mode; `builtin-exa-fetch` returns content as-is). The `ask` command (`sibyl ask `) passes the URL as the ask plugin's first arg; analogously, an ask plugin may itself fetch that URL via `context.configuredPlugins.fetch` before answering (`builtin-ai-ask` does). `main` is exported and only auto-runs when the file is the actual CLI entry (`import.meta.url` vs `process.argv[1]` guard), so tests can import it without side effects. +- `src/cli.ts` — entry point. Ensures dirs + config exist, loads plugins, builds a `PluginContext` (`buildPluginContext`), and dispatches commands (`search`, `fetch`, `ask`, `setup`, `help`/`--help`/`-h`, `version`/`--version`). `search`, `fetch`, and `ask` are wired up via the async `handleSearch`/`handleFetch`/`handleAsk` helpers (awaited by `main`), each passing the context as the last arg to the selected plugin's `fn`. The `fetch` command prints the fetch plugin's output directly — the CLI doesn't dispatch a separate `parse` step, but a fetch plugin may itself run the configured parse plugin via `context.configuredPlugins.parse` (`builtin-brightdata-fetch`, `builtin-crawl4ai-fetch`, and `builtin-alterlab-fetch` do; `builtin-firecrawl-fetch` does only in its raw-HTML mode; `builtin-exa-fetch` returns content as-is). The `ask` command (`sibyl ask `) passes the URL as the ask plugin's first arg; analogously, an ask plugin may itself fetch that URL via `context.configuredPlugins.fetch` before answering (`builtin-ai-ask` does). The `setup` command is handled synchronously by `runSetup(rest)` (`src/setup-command.ts`) and uses neither plugins nor the `PluginContext` (see The `setup` command). `main` is exported and only auto-runs when the file is the actual CLI entry (`import.meta.url` vs `process.argv[1]` guard), so tests can import it without side effects. - `src/setup.ts` — ensures `~/.sibyl` and `~/.sibyl/plugins` exist, and loads/creates/validates `~/.sibyl/config.json` (all on every invocation). +- `src/setup-command.ts` — implements the `setup` command (`runSetup`): installs the instructions doc into agent tools' global instruction files (see The `setup` command). Distinct from `setup.ts`, which is config bootstrap. +- `src/instructions.ts` — the bundled `SIBYL.md` content (`SIBYL_INSTRUCTIONS`) plus the import line, marker, and notice constants used by `setup-command.ts`. Inlined as a string constant (not read from disk) so it ships in `dist`. - `src/plugin-loader.ts` — assembles the active plugin set: builtin plugins + external (on-disk) plugins; validates the external ones. - `src/plugins/config.ts` — `getBuiltinPlugins()`, the in-repo builtin plugin registry. - `src/utils.ts` — pure helpers: `isValidHttpUrl`, `stripSearchResultDatePrefix` (strips localized SERP date prefixes), `collapseBlankLines`, and the search-setting readers `getSearchResultsLimit` / `shouldShowSearchDescription` (see Conventions). @@ -79,6 +81,18 @@ Shape: `SibylConfig` (`src/@types/sibyl-config.ts`) — `{ plugins: Partial`) + +`runSetup(args)` (`src/setup-command.ts`) installs the bundled instructions doc into an agent tool's **global** (user-level home-dir) instruction files. It parses per-target flags — at least one is required, else it prints usage and `exit(1)` (also on an unknown flag or an `--other` with no path): + +- `--claude` → writes `~/.claude/SIBYL.md` and adds a `@SIBYL.md` import line to `~/.claude/CLAUDE.md`. +- `--opencode` → writes `~/.config/opencode/SIBYL.md` and adds `~/.config/opencode/SIBYL.md` to the `instructions[]` array in `~/.config/opencode/opencode.json`. +- `--codex` → embeds the content into `~/.codex/AGENTS.md`. +- `--antigravity` → embeds the content into `~/.gemini/GEMINI.md`. +- `--other ` (repeatable; `--other=` also accepted) → embeds the content into an arbitrary file. + +Two mechanisms: Claude and opencode **reference** a `SIBYL.md` file each target owns; Codex, Antigravity, and `--other` **embed** the content between `` / `` markers, followed by a do-not-edit notice line. Everything is idempotent and refreshes in place on re-run — doc files are overwritten, the marker block (and its trailing notice) is regex-replaced, and the `@SIBYL.md` line / opencode entry are added at most once. The content is inlined in `src/instructions.ts` (a bundled string constant) because the `tsc`-only build has no asset-copy step, so a raw `.md` would never reach `dist`. + ## Conventions - ESM only (`"type": "module"`), Node `>=22`. diff --git a/README.md b/README.md index ebee671..afb49f4 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,7 @@ Get a working setup in a few steps: 1. Install **Sibyl** globally via NPM: ```bash - # ⚠️ Not yet available on npm - npm i -g sibyl + npm i -g @postapsis/sibyl ``` 2. Run a local [SearXNG](https://github.com/searxng/searxng) instance for searching the web: @@ -65,24 +64,35 @@ Get a working setup in a few steps: unclecode/crawl4ai:latest ``` -4. Run your first search: +4. Set up your agent to use Sibyl: + + ```bash + sibyl setup --claude + sibyl setup --codex + sibyl setup --opencode + sibyl setup --antigravity + sibyl setup --other ~/path/to/AGENTS.md + ``` + +5. Run your first search: ```bash sibyl search "how to use react with vite" ``` -5. Configure your settings!\ +6. Configure your settings!\ Check the [Configuration](#configuration) section for more details. ## Commands -| Command | Description | -| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| search | Searches the web
`sibyl search "react vite"` | -| fetch | Prints the content of a site in token-efficient markdown
`sibyl fetch https://vite.dev/guide` | -| ask | Asks a query using LLM from a site's content
`sibyl ask https://vite.dev/guide "how to start a react project with vite"` | -| `--help`, `-h` | Shows help. | -| `--version` | Shows version. | +| Command | Description | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| search | Searches the web
`sibyl search "react vite"` | +| fetch | Prints the content of a site in token-efficient markdown
`sibyl fetch https://vite.dev/guide` | +| ask | Asks a query using LLM from a site's content
`sibyl ask https://vite.dev/guide "how to start a react project with vite"` | +| setup | Installs the `SIBYL.md` instructions doc into an agent's global instruction files

`sibyl setup --claude --opencode --codex --antigravity`
Targets: `--claude`, `--opencode`, `--codex`, `--antigravity`, `--other ~/path/to/AGENTS.md` (embed into any file) | +| `--help`, `-h` | Shows help. | +| `--version` | Shows version. | ## Configuration diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index efa8e09..87b4be8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -78,9 +78,12 @@ Requires a SearXNG instance with the **JSON output format enabled**. See more at ### Crawl4AI Fetch -| Variable | Required | Default | Description | -| -------------------- | -------- | ------------------------ | ----------------------------------------------------------------------------------------- | -| `SIBYL_CRAWL4AI_URL` | No | `http://localhost:11235` | Base URL of a running Crawl4AI server. Sibyl uses the `/crawl` endpoint to fetch the data | +| Variable | Required | Default | Description | +| ------------------------------- | -------- | ------------------------ | ----------------------------------------------------------------------------------------- | +| `SIBYL_CRAWL4AI_URL` | No | `http://localhost:11235` | Base URL of a running Crawl4AI server. Sibyl uses the `/crawl` endpoint to fetch the data | +| `SIBYL_CRAWL4AI_PROXY_SERVER` | No | _(none)_ | Proxy server URL for the crawler. When unset, no proxy is used. | +| `SIBYL_CRAWL4AI_PROXY_USERNAME` | No | _(none)_ | Proxy auth username. Sent only when set and a proxy server is configured. | +| `SIBYL_CRAWL4AI_PROXY_PASSWORD` | No | _(none)_ | Proxy auth password. Sent only when set and a proxy server is configured. | Requires a Crawl4AI server, e.g., via Docker. See more at [https://hub.docker.com/r/unclecode/crawl4ai](https://hub.docker.com/r/unclecode/crawl4ai) diff --git a/package.json b/package.json index 75cae2c..c742159 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { - "name": "sibyl", + "name": "@postapsis/sibyl", "version": "0.1.0", "description": "Give your AI Agent the web, without the bloat — extensible and lightweight by design 🕷️", "type": "module", "packageManager": "pnpm@11.5.1", + "publishConfig": { + "access": "public" + }, "main": "dist/cli.js", "bin": { "sibyl": "dist/cli.js" diff --git a/src/cli.test.ts b/src/cli.test.ts index e89ef49..1ddbe60 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -12,6 +12,10 @@ vi.mock("./plugin-loader.ts", () => ({ loadPlugins: vi.fn(), })); +vi.mock("./setup-command.ts", () => ({ + runSetup: vi.fn(), +})); + vi.mock("./exit.ts", () => ({ exit: vi.fn(() => { throw new Error("process.exit"); @@ -22,6 +26,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vite import { main } from "./cli.ts"; import { loadOrCreateConfigFile } from "./setup.ts"; import { loadPlugins } from "./plugin-loader.ts"; +import { runSetup } from "./setup-command.ts"; import { exit } from "./exit.ts"; import type { AskPlugin, @@ -160,6 +165,13 @@ describe("dispatch & argument validation", () => { expect(console.log).toHaveBeenCalledWith(expect.stringContaining("sibyl - CLI tool")); expect(exit).toHaveBeenCalledWith(1); }); + + it("passes setup flags through to runSetup", async () => { + await main(["setup", "--claude", "--codex"]); + + expect(runSetup).toHaveBeenCalledWith(["--claude", "--codex"]); + expect(exit).not.toHaveBeenCalled(); + }); }); describe("handleSearch", () => { diff --git a/src/cli.ts b/src/cli.ts index 41b1cd2..074ecdf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ import type { import type { SibylConfig } from "./@types/sibyl-config.ts"; import { loadPlugins } from "./plugin-loader.ts"; import { isValidHttpUrl } from "./utils.ts"; +import { runSetup } from "./setup-command.ts"; import { exit } from "./exit.ts"; import { pathToFileURL } from "node:url"; import { realpathSync } from "node:fs"; @@ -82,6 +83,9 @@ export async function main(argv: string[]): Promise { await handleAsk(plugins, config, url, query, context); break; } + case "setup": + runSetup(rest); + break; case "--version": case "version": console.log("sibyl 0.1.0"); @@ -212,13 +216,19 @@ Commands: search Search the web fetch Fetch the content of a URL ask Answer a question using a URL's content via an LLM + setup Install SIBYL.md into an agent's instruction files help Show this help version Show version +Setup targets (at least one required): + --claude --opencode --codex --antigravity Install for that agent (global) + --other Embed instructions into any .MD agent instructions file + Examples: sibyl search "react vite" sibyl fetch https://vite.dev/guide sibyl ask https://vite.dev/guide "how do I start a react project with vite?" + sibyl setup --claude `); } diff --git a/src/instructions.ts b/src/instructions.ts new file mode 100644 index 0000000..e526a8a --- /dev/null +++ b/src/instructions.ts @@ -0,0 +1,45 @@ +/* + * Author: Jamius Siam + * Since: 14/07/2026 + */ + +// The bundled `SIBYL.md` instructions doc installed by `sibyl setup`. Inlined here (rather +// than read from disk) because the build is `tsc`-only with no asset-copy step, so a raw +// `.md` file would never reach `dist`. Assembled as a line array joined with newlines so the +// fenced code block below doesn't need its backticks escaped inside a template literal. +export const SIBYL_INSTRUCTIONS = [ + "# Search the web using `sibyl`", + "Use `sibyl` CLI for searching google/fetching webpages.", + "", + "## Usage", + "```", + "Commands:", + " search Search the web", + " fetch Fetch the content of a URL", + " help Show this help", + " version Show version", + "", + "Examples:", + ' sibyl search "react vite bootstrap"', + " sibyl fetch https://vite.dev/guide", + "```", + "", + "Always search the web first when installing a new library/framework to get the latest docs. Also search when you want to find out information/API DOC etc.", + "Search more often than not. Conduct multiple search and fetches using subagents (with Haiku) and then proceed.", + "Use your judgment when interpreting web search results. Do not rely only on the first result you find.", + "If the information is incomplete, unclear, or insufficient to make a confident decision, perform a few more searches or fetch additional sources before making changes.", + "", + "Collect all the necessary information thoroughly before proceeding with creating/editing code.", +].join("\n"); + +// Filename for the standalone doc that reference-based targets (Claude, opencode) point at. +export const SIBYL_DOC_FILENAME = "SIBYL.md"; + +// Import line added to Claude Code's CLAUDE.md (resolves relative to that file). +export const SIBYL_IMPORT_LINE = "@SIBYL.md"; + +// Sentinel markers wrapping the embedded content in tools that can't reference a file. +export const SIBYL_BLOCK_START = ""; +export const SIBYL_BLOCK_END = ""; +export const SIBYL_BLOCK_NOTICE = + ""; diff --git a/src/plugins/builtin-crawl4ai-fetch/main.test.ts b/src/plugins/builtin-crawl4ai-fetch/main.test.ts index 3666e69..56972ec 100644 --- a/src/plugins/builtin-crawl4ai-fetch/main.test.ts +++ b/src/plugins/builtin-crawl4ai-fetch/main.test.ts @@ -61,6 +61,9 @@ beforeEach(() => { parseFn.mockClear(); envSnapshot = { ...process.env }; delete process.env.SIBYL_CRAWL4AI_URL; + delete process.env.SIBYL_CRAWL4AI_PROXY_SERVER; + delete process.env.SIBYL_CRAWL4AI_PROXY_USERNAME; + delete process.env.SIBYL_CRAWL4AI_PROXY_PASSWORD; }); afterEach(() => { @@ -194,4 +197,47 @@ describe("builtin-crawl4ai-fetch", () => { expect.objectContaining({ method: "POST" }), ); }); + + it("omits `proxy_config` when `SIBYL_CRAWL4AI_PROXY_SERVER` is unset", async () => { + const fetchMock = stubFetch(makeResponse({ json: { success: true, results: [] } })); + + await fetchFn(url, context); + + const mockCallArgs = fetchMock.mock.calls[0] as RequestInit[]; + const req = mockCallArgs[1]; + expect(req?.body).not.toContain("proxy_config"); + }); + + it("includes `proxy_config` with server, username and password when all are set", async () => { + process.env.SIBYL_CRAWL4AI_PROXY_SERVER = "https://proxy.example.com:823"; + process.env.SIBYL_CRAWL4AI_PROXY_USERNAME = "user"; + process.env.SIBYL_CRAWL4AI_PROXY_PASSWORD = "pass"; + const fetchMock = stubFetch(makeResponse({ json: { success: true, results: [] } })); + + await fetchFn(url, context); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining( + '"proxy_config":{"server":"https://proxy.example.com:823","username":"user","password":"pass"}', + ), + }), + ); + }); + + it("includes `proxy_config` with only server when username and password are unset", async () => { + process.env.SIBYL_CRAWL4AI_PROXY_SERVER = "https://proxy.example.com:823"; + const fetchMock = stubFetch(makeResponse({ json: { success: true, results: [] } })); + + await fetchFn(url, context); + + const mockCallArgs = fetchMock.mock.calls[0] as RequestInit[]; + const req = mockCallArgs[1]; + const body = req?.body; + + expect(body).toContain('"proxy_config":{"server":"https://proxy.example.com:823"}'); + expect(body).not.toContain("username"); + expect(body).not.toContain("password"); + }); }); diff --git a/src/plugins/builtin-crawl4ai-fetch/main.ts b/src/plugins/builtin-crawl4ai-fetch/main.ts index b069aff..181cad8 100644 --- a/src/plugins/builtin-crawl4ai-fetch/main.ts +++ b/src/plugins/builtin-crawl4ai-fetch/main.ts @@ -22,6 +22,8 @@ async function fetchFn(url: string, context: PluginContext): Promise { const crawl4AiUrl = process.env.SIBYL_CRAWL4AI_URL ?? "http://localhost:11235"; const crawl4AiCrawlApiUrl = crawl4AiUrl + "/crawl"; + const proxyServer = process.env.SIBYL_CRAWL4AI_PROXY_SERVER; + let res: Response; try { @@ -44,6 +46,15 @@ async function fetchFn(url: string, context: PluginContext): Promise { simulate_user: true, override_navigator: true, wait_until: "networkidle", + ...(proxyServer + ? { + proxy_config: { + server: proxyServer, + username: process.env.SIBYL_CRAWL4AI_PROXY_USERNAME, + password: process.env.SIBYL_CRAWL4AI_PROXY_PASSWORD, + }, + } + : {}), }, }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), diff --git a/src/setup-command.test.ts b/src/setup-command.test.ts new file mode 100644 index 0000000..03344a6 --- /dev/null +++ b/src/setup-command.test.ts @@ -0,0 +1,167 @@ +/* + * Author: Jamius Siam + * Since: 14/07/2026 + */ +vi.mock("./exit.ts", () => ({ + exit: vi.fn(() => { + throw new Error("process.exit"); + }), +})); + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { runSetup } from "./setup-command.ts"; +import { exit } from "./exit.ts"; +import { + SIBYL_BLOCK_END, + SIBYL_BLOCK_NOTICE, + SIBYL_BLOCK_START, + SIBYL_INSTRUCTIONS, +} from "./instructions.ts"; + +let home: string; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "sibyl-setup-")); + vi.spyOn(os, "homedir").mockReturnValue(home); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(home, { recursive: true, force: true }); +}); + +const read = (...segments: string[]): string => + fs.readFileSync(path.join(home, ...segments), "utf8"); + +describe("argument validation", () => { + it("errors and exits when no target flags are given", () => { + expect(() => runSetup([])).toThrow("process.exit"); + + expect(exit).toHaveBeenCalledWith(1); + expect(fs.existsSync(path.join(home, ".claude"))).toBe(false); + }); + + it("errors on an unknown flag", () => { + expect(() => runSetup(["--bogus"])).toThrow("process.exit"); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("errors when --other has no path", () => { + expect(() => runSetup(["--other"])).toThrow("process.exit"); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("errors when --other is followed by another flag", () => { + expect(() => runSetup(["--other", "--claude"])).toThrow("process.exit"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("claude target", () => { + it("writes SIBYL.md and the @SIBYL.md import, leaving other tools untouched", () => { + runSetup(["--claude"]); + + expect(read(".claude", "SIBYL.md").trimEnd()).toBe(SIBYL_INSTRUCTIONS); + expect(read(".claude", "CLAUDE.md")).toContain("@SIBYL.md"); + + expect(fs.existsSync(path.join(home, ".config", "opencode"))).toBe(false); + expect(fs.existsSync(path.join(home, ".codex"))).toBe(false); + expect(fs.existsSync(path.join(home, ".gemini"))).toBe(false); + expect(exit).not.toHaveBeenCalled(); + }); + + it("does not duplicate the import on re-run", () => { + runSetup(["--claude"]); + runSetup(["--claude"]); + + expect(read(".claude", "CLAUDE.md").match(/@SIBYL\.md/g)).toHaveLength(1); + }); + + it("frames the import with blank lines after existing content", () => { + const claudeMd = path.join(home, ".claude", "CLAUDE.md"); + fs.mkdirSync(path.dirname(claudeMd), { recursive: true }); + fs.writeFileSync(claudeMd, "hello\n"); + + runSetup(["--claude"]); + + expect(fs.readFileSync(claudeMd, "utf8")).toBe("hello\n\n@SIBYL.md\n\n"); + }); +}); + +describe("opencode target", () => { + it("writes its own SIBYL.md and references it via instructions[]", () => { + runSetup(["--opencode"]); + + expect(fs.existsSync(path.join(home, ".config", "opencode", "SIBYL.md"))).toBe(true); + + const config = JSON.parse(read(".config", "opencode", "opencode.json")) as { + instructions: string[]; + }; + expect(config.instructions).toContain("~/.config/opencode/SIBYL.md"); + expect(fs.existsSync(path.join(home, ".claude"))).toBe(false); + }); + + it("preserves existing keys/entries and does not duplicate on re-run", () => { + const jsonPath = path.join(home, ".config", "opencode", "opencode.json"); + fs.mkdirSync(path.dirname(jsonPath), { recursive: true }); + fs.writeFileSync(jsonPath, JSON.stringify({ theme: "dark", instructions: ["AGENTS.md"] })); + + runSetup(["--opencode"]); + runSetup(["--opencode"]); + + const config = JSON.parse(fs.readFileSync(jsonPath, "utf8")) as { + theme: string; + instructions: string[]; + }; + expect(config.theme).toBe("dark"); + expect(config.instructions).toEqual(["AGENTS.md", "~/.config/opencode/SIBYL.md"]); + }); +}); + +describe("embed targets", () => { + it.each([ + ["--codex", [".codex", "AGENTS.md"]], + ["--antigravity", [".gemini", "GEMINI.md"]], + ])("embeds a marker block with a do-not-edit notice for %s", (flag, segments) => { + runSetup([flag]); + + const content = read(...(segments as string[])); + expect(content).toContain(SIBYL_BLOCK_START); + expect(content).toContain(SIBYL_BLOCK_END); + expect(content).toContain("Collect all the necessary information"); + expect(content).toContain(SIBYL_BLOCK_NOTICE); + }); + + it("embeds into an arbitrary --other file", () => { + const target = path.join(home, "notes.md"); + + runSetup(["--other", target]); + + const content = fs.readFileSync(target, "utf8"); + expect(content).toContain(SIBYL_BLOCK_START); + expect(content).toContain(SIBYL_BLOCK_NOTICE); + }); + + it("refreshes an existing block in place instead of duplicating it", () => { + const agents = path.join(home, ".codex", "AGENTS.md"); + fs.mkdirSync(path.dirname(agents), { recursive: true }); + fs.writeFileSync( + agents, + `# top\n\n${SIBYL_BLOCK_START}\nOLD STALE\n${SIBYL_BLOCK_END}\n${SIBYL_BLOCK_NOTICE}\n`, + ); + + runSetup(["--codex"]); + + const content = fs.readFileSync(agents, "utf8"); + expect(content).toContain("# top"); + expect(content).not.toContain("OLD STALE"); + expect(content).toContain("Collect all the necessary information"); + expect(content.split(SIBYL_BLOCK_START)).toHaveLength(2); // marker appears exactly once + expect(content.split(SIBYL_BLOCK_NOTICE)).toHaveLength(2); + }); +}); diff --git a/src/setup-command.ts b/src/setup-command.ts new file mode 100644 index 0000000..b03efbf --- /dev/null +++ b/src/setup-command.ts @@ -0,0 +1,194 @@ +/* + * Author: Jamius Siam + * Since: 14/07/2026 + */ +import path from "path"; +import fs from "fs"; +import os from "os"; +import { exit } from "./exit.ts"; +import { + SIBYL_BLOCK_END, + SIBYL_BLOCK_NOTICE, + SIBYL_BLOCK_START, + SIBYL_DOC_FILENAME, + SIBYL_IMPORT_LINE, + SIBYL_INSTRUCTIONS, +} from "./instructions.ts"; + +type SetupTarget = "claude" | "opencode" | "codex" | "antigravity"; + +// Path opencode stores in its `instructions[]` array. Kept in tilde form (opencode expands +// `~`) so it stays portable, while the doc itself is written to the expanded path. +const OPENCODE_INSTRUCTION_REF = `~/.config/opencode/${SIBYL_DOC_FILENAME}`; + +const SETUP_USAGE = `Usage: sibyl setup + +Targets (at least one required): + --claude Install into ~/.claude (CLAUDE.md ${SIBYL_IMPORT_LINE} import + SIBYL.md) + --opencode Install into ~/.config/opencode (opencode.json instructions + SIBYL.md) + --codex Embed instructions into ~/.codex/AGENTS.md + --antigravity Embed instructions into ~/.gemini/GEMINI.md + --other Embed instructions into an arbitrary file (repeatable)`; + +export function runSetup(args: string[]): void { + const { targets, otherPaths } = parseSetupArgs(args); + + try { + const home = os.homedir(); + + if (targets.has("claude")) { + writeDoc(path.join(home, ".claude", SIBYL_DOC_FILENAME)); + addImportLine(path.join(home, ".claude", "CLAUDE.md")); + } + + if (targets.has("opencode")) { + writeDoc(path.join(home, ".config", "opencode", SIBYL_DOC_FILENAME)); + addOpencodeInstruction(path.join(home, ".config", "opencode", "opencode.json")); + } + + if (targets.has("codex")) { + embedBlock(path.join(home, ".codex", "AGENTS.md")); + } + + if (targets.has("antigravity")) { + embedBlock(path.join(home, ".gemini", "GEMINI.md")); + } + + for (const other of otherPaths) { + embedBlock(path.resolve(other)); + } + } catch (error) { + console.error(`Error running setup: ${error}`); + exit(1); + } +} + +function parseSetupArgs(args: string[]): { targets: Set; otherPaths: string[] } { + const targets = new Set(); + const otherPaths: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i] as string; // safe: `i < args.length` + + switch (arg) { + case "--claude": + case "--opencode": + case "--codex": + case "--antigravity": + targets.add(arg.slice(2) as SetupTarget); + break; + case "--other": { + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + usageError("`--other` requires a file path"); + } + otherPaths.push(next); + i++; + break; + } + default: + if (arg.startsWith("--other=")) { + const value = arg.slice("--other=".length); + if (value === "") { + usageError("`--other` requires a file path"); + } + otherPaths.push(value); + } else { + usageError(`Unknown setup option: ${arg}`); + } + } + } + + if (targets.size === 0 && otherPaths.length === 0) { + usageError("At least one target is required"); + } + + return { targets, otherPaths }; +} + +function usageError(message: string): never { + console.error(message); + console.error(SETUP_USAGE); + return exit(1); +} + +// Writes the standalone SIBYL.md doc, overwriting any existing copy (refresh in place). +function writeDoc(file: string): void { + ensureDir(file); + fs.writeFileSync(file, `${SIBYL_INSTRUCTIONS}\n`); + console.log(`Wrote instructions doc: ${file}`); +} + +// Adds the `@SIBYL.md` import line once, framed by blank lines (blank line before, the line, +// a blank line after). Idempotent — a line already equal to the import is left untouched. +function addImportLine(file: string): void { + ensureDir(file); + const current = readIfExists(file); + + const present = new RegExp(`^\\s*${escapeRegExp(SIBYL_IMPORT_LINE)}\\s*$`, "m").test(current); + if (present) { + console.log(`${SIBYL_IMPORT_LINE} already present in ${file}`); + return; + } + + const base = current.replace(/\s+$/, ""); + const next = base.length ? `${base}\n\n${SIBYL_IMPORT_LINE}\n\n` : `${SIBYL_IMPORT_LINE}\n\n`; + fs.writeFileSync(file, next); + console.log(`Added ${SIBYL_IMPORT_LINE} to ${file}`); +} + +// Adds the doc path to opencode.json's `instructions[]` once, preserving all other config. +function addOpencodeInstruction(jsonPath: string): void { + ensureDir(jsonPath); + const raw = readIfExists(jsonPath).trim(); + const config: Record = raw ? (JSON.parse(raw) as Record) : {}; + + const existing = config.instructions; + const list: unknown[] = Array.isArray(existing) ? existing : []; + + if (list.includes(OPENCODE_INSTRUCTION_REF)) { + console.log(`opencode instructions already reference ${OPENCODE_INSTRUCTION_REF}`); + return; + } + + list.push(OPENCODE_INSTRUCTION_REF); + config.instructions = list; + fs.writeFileSync(jsonPath, `${JSON.stringify(config, null, 2)}\n`); + console.log(`Added ${OPENCODE_INSTRUCTION_REF} to opencode instructions in ${jsonPath}`); +} + +// Embeds the instructions inside sentinel markers followed by a do-not-edit notice. On +// re-run the existing block (and its trailing notice) is replaced in place, not duplicated. +function embedBlock(file: string): void { + ensureDir(file); + const current = readIfExists(file); + + const block = `${SIBYL_BLOCK_START}\n${SIBYL_INSTRUCTIONS}\n${SIBYL_BLOCK_END}\n${SIBYL_BLOCK_NOTICE}`; + const blockRe = new RegExp( + `${escapeRegExp(SIBYL_BLOCK_START)}[\\s\\S]*?${escapeRegExp(SIBYL_BLOCK_END)}` + + `(?:\\n${escapeRegExp(SIBYL_BLOCK_NOTICE)})?`, + ); + + if (blockRe.test(current)) { + fs.writeFileSync(file, current.replace(blockRe, block)); + console.log(`Refreshed SIBYL block in ${file}`); + return; + } + + const base = current.replace(/\s+$/, ""); + const next = base.length ? `${base}\n\n${block}\n` : `${block}\n`; + fs.writeFileSync(file, next); + console.log(`Embedded SIBYL block in ${file}`); +} + +function ensureDir(file: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); +} + +function readIfExists(file: string): string { + return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +}