Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ personal brain wiki.

Then to ensure your documentation stays up-to-date, add the CI workflow for your Git provider to automatically open a PR or merge request with documentation updates:

- GitHub Actions: copy [openwiki-update.yml](./examples/openwiki-update.yml) into `.github/workflows/openwiki-update.yml`.
- GitHub Actions: OpenWiki writes `.github/workflows/openwiki-update.yml` for you when it detects a GitHub remote. You can also copy [openwiki-update.yml](./examples/openwiki-update.yml) manually.
- GitLab CI: copy [openwiki-update.gitlab-ci.yml](./examples/openwiki-update.gitlab-ci.yml) into `.gitlab-ci.yml` or include it from your existing GitLab pipeline.
- Bitbucket Pipelines: copy [openwiki-update.bitbucket-pipelines.yml](./examples/openwiki-update.bitbucket-pipelines.yml) into `bitbucket-pipelines.yml`, then schedule the `openwiki-update` custom pipeline from Repository settings > Pipelines > Schedules.

OpenWiki detects your Git host from the repository's remote and only writes the GitHub Actions workflow for GitHub repositories; GitLab and Bitbucket repos are left untouched (use the examples above). Override detection with `OPENWIKI_CI_PROVIDER=github|gitlab|bitbucket|none` — set it to `none` to stop OpenWiki from writing any CI workflow (for example in a pipeline that manages its own pull requests).

For repository documentation in GitHub Actions, use
`openwiki code --update --print`. You do not need to run `--init` in CI:
`--update` will create the initial `openwiki/` docs if they do not exist yet, as
Expand Down Expand Up @@ -158,7 +160,7 @@ Bare `openwiki` runs in code mode for the current repository. It creates initial

Bare `openwiki --init` and `openwiki --update` default to code mode and operate on repository documentation. Use the `personal` positional mode or `--mode personal` to initialize or update the local personal brain wiki.

On each `code` run, `openwiki` maintains both an `AGENTS.md` and a `CLAUDE.md` at the repository root, adding prompting that instructs your coding agent to reference the wiki when searching for context. Each file is created if it does not already exist. If a file is present, OpenWiki only rewrites its own `<!-- OPENWIKI:START -->…<!-- OPENWIKI:END -->` block and leaves the rest of your content untouched (appending the block the first time). The scheduled GitHub Actions workflow includes these files, along with the workflow itself, in the documentation pull request.
On each `code` run, `openwiki` maintains both an `AGENTS.md` and a `CLAUDE.md` at the repository root, adding prompting that instructs your coding agent to reference the wiki when searching for context. Each file is created if it does not already exist. If a file is present, OpenWiki only rewrites its own `<!-- OPENWIKI:START -->…<!-- OPENWIKI:END -->` block and leaves the rest of your content untouched (appending the block the first time). The block's wording matches your detected Git host (GitHub Actions workflow, GitLab pipeline, Bitbucket pipeline, or a provider-neutral update job). The scheduled GitHub Actions workflow includes these files, along with the workflow itself, in the documentation pull request.

Repository-specific wiki instructions are stored separately in
`openwiki/INSTRUCTIONS.md`. This file is a shared, user-authored brief for the
Expand Down
8 changes: 6 additions & 2 deletions examples/openwiki-update.bitbucket-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@ pipelines:
- npm install --global openwiki
- git config user.name "${BITBUCKET_USER_NAME:-OpenWiki Bot}"
- git config user.email "${BITBUCKET_USER_EMAIL:-openwiki@example.com}"
# Keep OpenWiki from writing a GitHub Actions workflow and make the
# AGENTS.md/CLAUDE.md wording reference Bitbucket. Detection from the
# git remote usually handles this; setting it here is bulletproof.
- export OPENWIKI_CI_PROVIDER=bitbucket
- openwiki code --update --print
- |
if git diff --quiet -- openwiki AGENTS.md CLAUDE.md .github/workflows/openwiki-update.yml; then
if git diff --quiet -- openwiki AGENTS.md CLAUDE.md; then
echo "OpenWiki is already up to date."
exit 0
fi
- export OPENWIKI_BRANCH="openwiki/update-${BITBUCKET_BUILD_NUMBER}"
- git checkout -b "$OPENWIKI_BRANCH"
- git add openwiki AGENTS.md CLAUDE.md .github/workflows/openwiki-update.yml
- git add openwiki AGENTS.md CLAUDE.md
- git commit -m "docs: update OpenWiki"
- git push "https://x-token-auth:${OPENWIKI_BITBUCKET_TOKEN}@bitbucket.org/${BITBUCKET_WORKSPACE}/${BITBUCKET_REPO_SLUG}.git" "$OPENWIKI_BRANCH"
- |
Expand Down
8 changes: 6 additions & 2 deletions examples/openwiki-update.gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ openwiki_update:
script:
- openwiki code --update --print
- |
if git diff --quiet -- openwiki AGENTS.md CLAUDE.md .github/workflows/openwiki-update.yml; then
if git diff --quiet -- openwiki AGENTS.md CLAUDE.md; then
echo "OpenWiki is already up to date."
exit 0
fi
- export OPENWIKI_BRANCH="openwiki/update-${CI_PIPELINE_ID}"
- git checkout -b "$OPENWIKI_BRANCH"
- git add openwiki AGENTS.md CLAUDE.md .github/workflows/openwiki-update.yml
- git add openwiki AGENTS.md CLAUDE.md
- git commit -m "docs: update OpenWiki"
- git push "https://oauth2:${OPENWIKI_GITLAB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" "$OPENWIKI_BRANCH"
- |
Expand All @@ -30,6 +30,10 @@ openwiki_update:
--form "description=Automated OpenWiki documentation update generated by the scheduled GitLab pipeline." \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests"
variables:
# Keep OpenWiki from writing a GitHub Actions workflow and make the
# AGENTS.md/CLAUDE.md wording reference GitLab. Detection from the git
# remote usually handles this, but setting it explicitly is bulletproof.
OPENWIKI_CI_PROVIDER: gitlab
OPENWIKI_PROVIDER: openrouter
OPENWIKI_MODEL_ID: z-ai/glm-5.2
LANGCHAIN_PROJECT: openwiki
Expand Down
131 changes: 125 additions & 6 deletions src/code-mode.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { isFileNotFoundError } from "./fs-errors.js";

const execFileAsync = promisify(execFile);

const OPENWIKI_AGENTS_SNIPPET_START = "<!-- OPENWIKI:START -->";
const OPENWIKI_AGENTS_SNIPPET_END = "<!-- OPENWIKI:END -->";
const DEFAULT_CODE_MODE_CRON = "0 8 * * *";

// Which CI host OpenWiki tailors its generated artifacts to. Only "github"
// gets a committed CI workflow (GitHub Actions); the others ship as examples/
// the user wires up themselves, so we just keep the agent-file wording honest.
type CiProvider = "github" | "gitlab" | "bitbucket" | "none";

// Environment override for provider detection, useful for self-hosted hosts
// whose remote URL doesn't advertise the provider, or to opt out entirely
// (e.g. `OPENWIKI_CI_PROVIDER=none` in a CI pipeline that manages its own PRs).
const CI_PROVIDER_ENV_KEY = "OPENWIKI_CI_PROVIDER";

// Root agent-instruction files OpenWiki keeps pointed at the generated wiki.
// Each is created when missing and refreshed in place when already present.
const CODE_MODE_AGENT_FILES = ["AGENTS.md", "CLAUDE.md"];
Expand All @@ -14,8 +28,95 @@ export async function ensureCodeModeRepoSetup(
cwd: string,
cronExpression = DEFAULT_CODE_MODE_CRON,
): Promise<void> {
await writeCodeModeWorkflow(cwd, cronExpression);
await writeCodeModeAgentSnippets(cwd);
const provider = await detectCiProvider(cwd);

if (provider === "github") {
await writeCodeModeWorkflow(cwd, cronExpression);
}

await writeCodeModeAgentSnippets(cwd, provider);
}

async function detectCiProvider(cwd: string): Promise<CiProvider> {
const override = normalizeCiProvider(process.env[CI_PROVIDER_ENV_KEY]);
if (override) {
return override;
}

return classifyRemoteHost(await readGitRemoteUrl(cwd));
}

function normalizeCiProvider(value: string | undefined): CiProvider | null {
switch (value?.trim().toLowerCase()) {
case "github":
return "github";
case "gitlab":
return "gitlab";
case "bitbucket":
return "bitbucket";
case "none":
return "none";
default:
return null;
}
}

// Reads the repo's push/fetch remote URL so we can infer the git host. Prefers
// `origin`, falling back to whatever remote exists. Returns null when there is
// no git repo or no remote configured.
async function readGitRemoteUrl(cwd: string): Promise<string | null> {
const originUrl = await runGitQuietly(cwd, ["remote", "get-url", "origin"]);
if (originUrl) {
return originUrl;
}

const remotes = await runGitQuietly(cwd, ["remote"]);
const firstRemote = remotes
?.split(/\r?\n/u)
.map((line) => line.trim())
.find(Boolean);
if (!firstRemote) {
return null;
}

return runGitQuietly(cwd, ["remote", "get-url", firstRemote]);
}

async function runGitQuietly(
cwd: string,
args: string[],
): Promise<string | null> {
try {
const { stdout } = await execFileAsync("git", args, {
cwd,
maxBuffer: 1024 * 1024,
});
const trimmed = stdout.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
// No git binary, not a repo, or no such remote — treat as undetectable.
return null;
}
}

// Classifies a git remote URL by host. Enterprise/self-hosted GitHub and GitLab
// instances typically keep the provider name in their hostname, so a substring
// match is deliberate. Unknown hosts (and missing remotes) fall back to GitHub
// to preserve the historical default; use OPENWIKI_CI_PROVIDER to override.
function classifyRemoteHost(remoteUrl: string | null): CiProvider {
if (!remoteUrl) {
return "github";
}

const lower = remoteUrl.toLowerCase();
if (lower.includes("gitlab")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it safe to match on the entire url? should we just be matching on the host part? for example, what if someone has something like:

git@github.com:acme/gitlab-importer.git

return "gitlab";
}
if (lower.includes("bitbucket")) {
return "bitbucket";
}

return "github";
}

async function writeCodeModeWorkflow(
Expand All @@ -32,8 +133,11 @@ async function writeCodeModeWorkflow(
await writeFile(workflowPath, createCodeModeWorkflow(cronExpression), "utf8");
}

async function writeCodeModeAgentSnippets(cwd: string): Promise<void> {
const snippet = createCodeModeAgentsSnippet();
async function writeCodeModeAgentSnippets(
cwd: string,
provider: CiProvider,
): Promise<void> {
const snippet = createCodeModeAgentsSnippet(provider);

await Promise.all(
CODE_MODE_AGENT_FILES.map((fileName) =>
Expand Down Expand Up @@ -121,14 +225,29 @@ jobs:
`;
}

function createCodeModeAgentsSnippet(): string {
function createCodeModeAgentsSnippet(provider: CiProvider): string {
return `${OPENWIKI_AGENTS_SNIPPET_START}

## OpenWiki

This repository uses OpenWiki for recurring code documentation. Start with \`openwiki/quickstart.md\`, then follow its links to architecture, workflows, domain concepts, operations, integrations, testing guidance, and source maps.

The scheduled OpenWiki GitHub Actions workflow refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate.
The ${describeCodeModeUpdateJob(provider)} refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate.

${OPENWIKI_AGENTS_SNIPPET_END}`;
}

// Provider-aware phrasing for the OPENWIKI agent-file block so the sentence is
// accurate on non-GitHub hosts instead of always claiming a GitHub Actions run.
function describeCodeModeUpdateJob(provider: CiProvider): string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need a default case?

switch (provider) {
case "github":
return "scheduled OpenWiki GitHub Actions workflow";
case "gitlab":
return "scheduled OpenWiki GitLab pipeline";
case "bitbucket":
return "scheduled OpenWiki Bitbucket pipeline";
case "none":
return "scheduled OpenWiki update job";
}
}
129 changes: 129 additions & 0 deletions test/code-mode.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, test } from "vitest";
import { ensureCodeModeRepoSetup } from "../src/code-mode.ts";

const execFileAsync = promisify(execFile);

const SNIPPET_START = "<!-- OPENWIKI:START -->";
const SNIPPET_END = "<!-- OPENWIKI:END -->";

const CI_PROVIDER_ENV_KEY = "OPENWIKI_CI_PROVIDER";

const tempRepos: string[] = [];

async function createTempRepo(): Promise<string> {
Expand All @@ -15,6 +21,39 @@ async function createTempRepo(): Promise<string> {
return repo;
}

const WORKFLOW_RELATIVE_PATH = path.join(
".github",
"workflows",
"openwiki-update.yml",
);

async function withCiProvider(
value: string,
run: () => Promise<void>,
): Promise<void> {
const previous = process.env[CI_PROVIDER_ENV_KEY];
process.env[CI_PROVIDER_ENV_KEY] = value;
try {
await run();
} finally {
if (previous === undefined) {
delete process.env[CI_PROVIDER_ENV_KEY];
} else {
process.env[CI_PROVIDER_ENV_KEY] = previous;
}
}
}

async function initGitRepoWithRemote(
repo: string,
remoteUrl: string,
): Promise<void> {
await execFileAsync("git", ["init"], { cwd: repo });
await execFileAsync("git", ["remote", "add", "origin", remoteUrl], {
cwd: repo,
});
}

async function readIfPresent(filePath: string): Promise<string | null> {
try {
return await readFile(filePath, "utf8");
Expand Down Expand Up @@ -119,4 +158,94 @@ describe("ensureCodeModeRepoSetup workflow", () => {
expect(workflow).toContain(managedPath);
}
});

test("defaults to GitHub wording and workflow when no git remote exists", async () => {
const repo = await createTempRepo();

await ensureCodeModeRepoSetup(repo);

const workflow = await readIfPresent(
path.join(repo, WORKFLOW_RELATIVE_PATH),
);
expect(
workflow,
"GitHub workflow should be written by default",
).not.toBeNull();

const claude = await readIfPresent(path.join(repo, "CLAUDE.md"));
expect(claude).toContain(
"scheduled OpenWiki GitHub Actions workflow refreshes",
);
});
});

describe("ensureCodeModeRepoSetup provider awareness", () => {
test.each([
{
provider: "gitlab",
wording: "scheduled OpenWiki GitLab pipeline refreshes",
},
{
provider: "bitbucket",
wording: "scheduled OpenWiki Bitbucket pipeline refreshes",
},
{
provider: "none",
wording: "scheduled OpenWiki update job refreshes",
},
])(
"skips the GitHub workflow and uses matching wording for $provider",
async ({ provider, wording }) => {
const repo = await createTempRepo();

await withCiProvider(provider, () => ensureCodeModeRepoSetup(repo));

const workflow = await readIfPresent(
path.join(repo, WORKFLOW_RELATIVE_PATH),
);
expect(
workflow,
`no GitHub workflow should be written for ${provider}`,
).toBeNull();

for (const fileName of ["AGENTS.md", "CLAUDE.md"]) {
const content = await readIfPresent(path.join(repo, fileName));
expect(content).toContain(wording);
expect(content).not.toContain("GitHub Actions workflow");
}
},
);

test("detects the provider from the git remote host", async () => {
const repo = await createTempRepo();
await initGitRepoWithRemote(repo, "git@bitbucket.org:acme/widgets.git");

await ensureCodeModeRepoSetup(repo);

const workflow = await readIfPresent(
path.join(repo, WORKFLOW_RELATIVE_PATH),
);
expect(
workflow,
"Bitbucket repos should not get a GitHub workflow",
).toBeNull();

const claude = await readIfPresent(path.join(repo, "CLAUDE.md"));
expect(claude).toContain("scheduled OpenWiki Bitbucket pipeline refreshes");
});

test("env override wins over the detected git remote host", async () => {
const repo = await createTempRepo();
await initGitRepoWithRemote(repo, "git@github.com:acme/widgets.git");

await withCiProvider("none", () => ensureCodeModeRepoSetup(repo));

const workflow = await readIfPresent(
path.join(repo, WORKFLOW_RELATIVE_PATH),
);
expect(workflow).toBeNull();

const claude = await readIfPresent(path.join(repo, "CLAUDE.md"));
expect(claude).toContain("scheduled OpenWiki update job refreshes");
});
});