diff --git a/CLAUDE.md b/CLAUDE.md index 770d5cc..19d870d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ kib — The Headless Knowledge Compiler. Bun + TypeScript monorepo. - `packages/core` → `@kibhq/core` on npm - `packages/cli` → `@kibhq/cli` on npm +- `packages/dashboard` → `@kibhq/dashboard` on npm (web UI, launched via `kib ui`) - npm org: `kibhq` - GitHub: `keeganthomp/kib` @@ -50,6 +51,24 @@ bun run packages/cli/bin/kib.ts # run CLI locally - LLM providers: Anthropic, OpenAI, Ollama (auto-detected from env vars) - Credentials stored at `~/.config/kib/credentials`, loaded on CLI startup +## Shared Workspaces + +Git-based team collaboration. No custom server, no accounts. + +```bash +kib share # connect vault to git remote +kib clone # join a shared vault +kib pull # get latest from team +kib push # commit + push local changes +kib share --status # check sync state +``` + +- Manifest conflicts auto-resolve via 3-way merge (union by key, prefer newer) +- Machine-local state (.kb/cache, .kb/vault.lock, pipeline.db) is gitignored +- Dashboard Team page: sync status, pull/push buttons, contributor list +- MCP tools: `kib_share_status`, `kib_pull`, `kib_push` +- Config: `[sharing]` section in config.toml + ## Skill Ecosystem (v0.8.0) ### Built-in Skills (10) diff --git a/README.md b/README.md index 408ee54..9e3944a 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,13 @@ INTEGRATION serve Start MCP server for AI tool integration mcp Configure MCP in AI clients (auto-runs on init) watch Passive learning daemon (inbox, folders, clipboard, screenshots) + ui Launch local web dashboard + +COLLABORATION + share Share vault with a team via git remote + clone [dir] Clone a shared vault from a git remote + pull Pull latest changes from shared vault + push Push local changes to shared vault MANAGEMENT config [key] [val] Get or set configuration @@ -277,6 +284,29 @@ glob = "*.{png,jpg,jpeg,webp,gif,bmp,tiff}" Failed ingestions retry up to 3 times before moving to the failed queue. Logs are written to `.kb/logs/watch.log` with automatic rotation at 10 MB. +### Shared Workspaces + +Share a vault with your team using git. No custom server, no accounts — just git. + +```bash +# Share your vault (one-time setup) +kib share https://github.com/team/research.git + +# Team members join +kib clone https://github.com/team/research.git + +# Day-to-day sync +kib pull # get latest from team +kib push # share your changes + +# Check sync status +kib share --status +``` + +Each team member ingests and compiles locally, then pushes to the shared remote. Manifest conflicts are auto-resolved via 3-way merge (union sources/articles by key, prefer newer). Machine-local state (cache, search index, lockfile) is gitignored. + +The web dashboard (`kib ui`) includes a Team page with sync status, pull/push buttons, and a contributor list. MCP tools (`kib_pull`, `kib_push`, `kib_share_status`) let AI assistants sync too. + ### Export ```bash @@ -351,7 +381,7 @@ That's it. Restart your AI client and it can search, query, ingest, and compile Already have a vault? Run `kib mcp` to configure MCP clients without re-initializing. -**11 tools:** `kib_status`, `kib_list`, `kib_read`, `kib_search`, `kib_query`, `kib_ingest`, `kib_compile`, `kib_lint`, `kib_config`, `kib_skill`, `kib_export` +**14 tools:** `kib_status`, `kib_list`, `kib_read`, `kib_search`, `kib_query`, `kib_ingest`, `kib_compile`, `kib_lint`, `kib_config`, `kib_skill`, `kib_export`, `kib_share_status`, `kib_pull`, `kib_push` **3 resources:** `wiki://index`, `wiki://graph`, `wiki://log` diff --git a/ROADMAP.md b/ROADMAP.md index b8f331c..f4f602f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -204,16 +204,19 @@ Most of kib's value is locked behind `kib compile`. That's wrong — value shoul CLI-only means developer-only. The knowledge is valuable to everyone. - [ ] VS Code extension: sidebar with search, query, ingest from editor - [ ] Obsidian plugin: sync kib vault ↔ Obsidian vault, use kib's compile + search -- [ ] Web UI: local dashboard with graph visualization, search, query (not just export) +- [x] Web UI: local dashboard with graph visualization, search, query, ingest, compile (`kib ui`) - [ ] Raycast/Alfred integration: global hotkey → search your knowledge base - [ ] Mobile: read-only PWA for querying on the go ### Shared Knowledge Bases Personal wikis are useful. Team wikis are essential. -- [ ] `kib share` — push vault to a git remote, team members clone + contribute -- [ ] Multi-user ingest: team members ingest from their own browsers, shared compile +- [x] `kib share ` — connect vault to git remote, push, team members clone + contribute +- [x] `kib clone ` — join a shared vault from a git remote +- [x] `kib pull` / `kib push` — sync changes with team (auto-commit, manifest auto-merge) +- [x] Multi-user ingest: team members ingest from their own machines, push to shared remote +- [x] Team dashboard: contributors, sync status, pull/push from web UI +- [x] MCP tools: `kib_share_status`, `kib_pull`, `kib_push` - [ ] Access control: public wiki articles vs private notes -- [ ] Team dashboard: who ingested what, what's new this week, knowledge gaps - [ ] Org-wide knowledge graph: connect team vaults into a federated search --- @@ -238,10 +241,11 @@ Personal wikis are useful. Team wikis are essential. - [ ] Shared vault hosting (GitHub repo as vault backend) ### Web UI -- [ ] `kib serve` — local web server with read-only wiki viewer -- [ ] Search interface in the browser -- [ ] Article graph visualization (force-directed graph) -- [ ] Reading mode with backlink sidebar +- [x] `kib ui` — local web dashboard (Bun server on port 4848, React + D3) +- [x] Search interface in the browser +- [x] Article graph visualization (D3 force-directed graph) +- [x] Reading mode for wiki and raw articles +- [ ] Backlink sidebar in reading mode ### Additional Export Formats - [ ] PDF export (via Puppeteer or wkhtmltopdf) diff --git a/packages/cli/src/commands/clone.ts b/packages/cli/src/commands/clone.ts new file mode 100644 index 0000000..45a0bda --- /dev/null +++ b/packages/cli/src/commands/clone.ts @@ -0,0 +1,67 @@ +import { basename, resolve } from "node:path"; +import * as log from "../ui/logger.js"; +import { createSpinner } from "../ui/spinner.js"; + +interface CloneOpts { + json?: boolean; +} + +export async function clone(remoteUrl: string, dir: string | undefined, opts: CloneOpts) { + if (!remoteUrl) { + log.error("Usage: kib clone [directory]"); + process.exit(1); + } + + // Default dir name from URL: git@github.com:user/my-vault.git → my-vault + const defaultDir = basename(remoteUrl, ".git").replace(/[^a-zA-Z0-9_-]/g, "-"); + const targetDir = resolve(dir ?? defaultDir); + + const spinner = opts.json ? null : createSpinner("Cloning shared vault..."); + spinner?.start(); + + try { + const { cloneVault, loadManifest, loadConfig } = await import("@kibhq/core"); + const root = await cloneVault(remoteUrl, targetDir); + const manifest = await loadManifest(root); + const config = await loadConfig(root); + + spinner?.stop(); + + if (opts.json) { + console.log( + JSON.stringify( + { + path: root, + vault: manifest.vault.name, + sources: Object.keys(manifest.sources).length, + articles: Object.keys(manifest.articles).length, + }, + null, + 2, + ), + ); + return; + } + + const sourceCount = Object.keys(manifest.sources).length; + const articleCount = Object.keys(manifest.articles).length; + + log.header("vault cloned"); + log.success(`Path: ${root}`); + log.success(`Vault: ${manifest.vault.name}`); + log.success(`Provider: ${config.provider.default} (${config.provider.model})`); + log.keyValue("sources", `${sourceCount}`); + log.keyValue("articles", `${articleCount}`); + log.blank(); + log.dim("start working:"); + log.dim(` cd ${targetDir}`); + log.dim(" kib ingest — add content"); + log.dim(" kib pull — get latest"); + log.dim(" kib push — share changes"); + log.blank(); + } catch (err) { + spinner?.stop(); + log.error((err as Error).message); + process.exit(1); + } +} diff --git a/packages/cli/src/commands/pull.ts b/packages/cli/src/commands/pull.ts new file mode 100644 index 0000000..9697fef --- /dev/null +++ b/packages/cli/src/commands/pull.ts @@ -0,0 +1,69 @@ +import { resolveVaultRoot, VaultNotFoundError } from "@kibhq/core"; +import { debug } from "../ui/debug.js"; +import * as log from "../ui/logger.js"; +import { createSpinner } from "../ui/spinner.js"; + +interface PullOpts { + json?: boolean; +} + +export async function pull(opts: PullOpts) { + let root: string; + try { + root = resolveVaultRoot(); + } catch (err) { + if (err instanceof VaultNotFoundError) { + log.error(err.message); + process.exit(1); + } + throw err; + } + + debug(`vault root: ${root}`); + + const spinner = opts.json ? null : createSpinner("Pulling from remote..."); + spinner?.start(); + + try { + const { pullVault } = await import("@kibhq/core"); + const result = await pullVault(root); + + spinner?.stop(); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + if (!result.updated) { + log.header("pull"); + log.dim("Already up to date."); + log.blank(); + return; + } + + log.header("pull"); + log.success(result.summary); + + if (result.newSources > 0) { + log.info(`${result.newSources} new source${result.newSources === 1 ? "" : "s"}`); + } + if (result.newArticles > 0) { + log.info(`${result.newArticles} new article${result.newArticles === 1 ? "" : "s"}`); + } + if (result.conflicts.length > 0) { + log.blank(); + log.warn("Unresolved conflicts:"); + for (const file of result.conflicts) { + log.warn(` ${file}`); + } + log.dim("Resolve these manually, then run kib push."); + } + + log.blank(); + } catch (err) { + spinner?.stop(); + log.error((err as Error).message); + process.exit(1); + } +} diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts new file mode 100644 index 0000000..e553155 --- /dev/null +++ b/packages/cli/src/commands/push.ts @@ -0,0 +1,56 @@ +import { resolveVaultRoot, VaultNotFoundError } from "@kibhq/core"; +import { debug } from "../ui/debug.js"; +import * as log from "../ui/logger.js"; +import { createSpinner } from "../ui/spinner.js"; + +interface PushOpts { + message?: string; + json?: boolean; +} + +export async function push(opts: PushOpts) { + let root: string; + try { + root = resolveVaultRoot(); + } catch (err) { + if (err instanceof VaultNotFoundError) { + log.error(err.message); + process.exit(1); + } + throw err; + } + + debug(`vault root: ${root}`); + + const spinner = opts.json ? null : createSpinner("Pushing to remote..."); + spinner?.start(); + + try { + const { pushVault } = await import("@kibhq/core"); + const result = await pushVault(root, opts.message); + + spinner?.stop(); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + log.header("push"); + + if (!result.pushed) { + log.dim("Nothing to push — vault is clean."); + log.blank(); + return; + } + + log.success( + `Pushed ${result.filesChanged} file${result.filesChanged === 1 ? "" : "s"} (${result.commit})`, + ); + log.blank(); + } catch (err) { + spinner?.stop(); + log.error((err as Error).message); + process.exit(1); + } +} diff --git a/packages/cli/src/commands/share.ts b/packages/cli/src/commands/share.ts new file mode 100644 index 0000000..c543107 --- /dev/null +++ b/packages/cli/src/commands/share.ts @@ -0,0 +1,119 @@ +import { resolveVaultRoot, VaultNotFoundError } from "@kibhq/core"; +import { debug } from "../ui/debug.js"; +import * as log from "../ui/logger.js"; +import { createSpinner } from "../ui/spinner.js"; + +interface ShareOpts { + json?: boolean; + status?: boolean; +} + +export async function share(remoteUrl: string | undefined, opts: ShareOpts) { + // kib share --status (or no args) + if (opts.status || !remoteUrl) { + return showStatus(opts); + } + + // kib share — connect vault to remote + let root: string; + try { + root = resolveVaultRoot(); + } catch (err) { + if (err instanceof VaultNotFoundError) { + log.error(err.message); + process.exit(1); + } + throw err; + } + + debug(`vault root: ${root}`); + debug(`remote: ${remoteUrl}`); + + const spinner = opts.json ? null : createSpinner("Setting up shared vault..."); + spinner?.start(); + + try { + const { shareVault } = await import("@kibhq/core"); + const result = await shareVault(root, remoteUrl); + + spinner?.stop(); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + log.header("vault shared"); + log.success(`Remote: ${result.remote}`); + log.success(`Branch: ${result.branch}`); + log.blank(); + log.dim("team members can join with:"); + log.dim(` kib clone ${remoteUrl}`); + log.blank(); + log.dim("day-to-day:"); + log.dim(" kib pull — get latest from team"); + log.dim(" kib push — share your changes"); + log.blank(); + } catch (err) { + spinner?.stop(); + log.error((err as Error).message); + process.exit(1); + } +} + +async function showStatus(opts: ShareOpts) { + let root: string; + try { + root = resolveVaultRoot(); + } catch (err) { + if (err instanceof VaultNotFoundError) { + log.error(err.message); + process.exit(1); + } + throw err; + } + + const { shareStatus } = await import("@kibhq/core"); + const status = await shareStatus(root); + + if (opts.json) { + console.log(JSON.stringify(status, null, 2)); + return; + } + + log.header("share status"); + + if (!status.shared) { + log.dim("Vault is not shared."); + log.dim("Run kib share to set up sharing."); + log.blank(); + return; + } + + log.keyValue("remote", status.remote ?? "unknown"); + log.keyValue("branch", status.branch ?? "unknown"); + log.keyValue("status", formatSyncStatus(status.ahead, status.behind)); + log.keyValue("local changes", status.dirty ? "yes" : "clean"); + + if (status.lastSync) { + log.keyValue("last sync", status.lastSync); + } + + if (status.contributors.length > 0) { + log.blank(); + log.dim("contributors:"); + for (const c of status.contributors.slice(0, 10)) { + log.dim(` ${c.name} — ${c.commits} commit${c.commits === 1 ? "" : "s"}`); + } + } + + log.blank(); +} + +function formatSyncStatus(ahead: number, behind: number): string { + if (ahead === 0 && behind === 0) return "up to date"; + const parts: string[] = []; + if (ahead > 0) parts.push(`${ahead} ahead`); + if (behind > 0) parts.push(`${behind} behind`); + return parts.join(", "); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index de7d209..b95d313 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -189,6 +189,44 @@ program await mcp(subcommand); }); +program + .command("share [remote-url]") + .description("Share vault with a team via git remote") + .option("--status", "show sharing status") + .option("--json", "JSON output") + .action(async (remoteUrl, opts) => { + const { share } = await import("./commands/share.js"); + await share(remoteUrl, opts); + }); + +program + .command("pull") + .description("Pull latest changes from shared vault") + .option("--json", "JSON output") + .action(async (opts) => { + const { pull } = await import("./commands/pull.js"); + await pull(opts); + }); + +program + .command("push") + .description("Push local changes to shared vault") + .option("-m, --message ", "custom commit message") + .option("--json", "JSON output") + .action(async (opts) => { + const { push } = await import("./commands/push.js"); + await push(opts); + }); + +program + .command("clone [directory]") + .description("Clone a shared vault from a git remote") + .option("--json", "JSON output") + .action(async (remoteUrl, dir, opts) => { + const { clone } = await import("./commands/clone.js"); + await clone(remoteUrl, dir, opts); + }); + program .command("export") .description("Export wiki to other formats") diff --git a/packages/cli/src/mcp/server.test.ts b/packages/cli/src/mcp/server.test.ts index bf20651..97dfcdc 100644 --- a/packages/cli/src/mcp/server.test.ts +++ b/packages/cli/src/mcp/server.test.ts @@ -568,9 +568,12 @@ describe("MCP server", () => { "kib_ingest", "kib_lint", "kib_list", + "kib_pull", + "kib_push", "kib_query", "kib_read", "kib_search", + "kib_share_status", "kib_skill", "kib_status", ]); diff --git a/packages/cli/src/mcp/server.ts b/packages/cli/src/mcp/server.ts index ce7e448..d358c6f 100644 --- a/packages/cli/src/mcp/server.ts +++ b/packages/cli/src/mcp/server.ts @@ -581,6 +581,64 @@ export function createMcpServer(root: string) { }, ); + // ── Sharing ─────────────────────────────────────────────── + + server.tool( + "kib_share_status", + "Check if the vault is shared and show sync status (ahead/behind, contributors, remote URL)", + {}, + async () => { + try { + const { isShared, shareStatus } = await import("@kibhq/core"); + if (!isShared(root)) { + return json({ + shared: false, + hint: "Run 'kib share ' to enable team collaboration", + }); + } + const status = await shareStatus(root); + return json(status); + } catch (e) { + return err((e as Error).message); + } + }, + ); + + server.tool( + "kib_pull", + "Pull latest changes from the shared vault remote. Auto-merges manifest conflicts.", + {}, + async () => { + try { + const { isShared, pullVault } = await import("@kibhq/core"); + if (!isShared(root)) return err("Vault is not shared. Run 'kib share ' first."); + const result = await pullVault(root); + ctx.invalidateSearch(); + return json(result); + } catch (e) { + return err((e as Error).message); + } + }, + ); + + server.tool( + "kib_push", + "Commit and push local vault changes to the shared remote.", + { + message: z.string().optional().describe("Custom commit message. Auto-generated if omitted."), + }, + async ({ message }) => { + try { + const { isShared, pushVault } = await import("@kibhq/core"); + if (!isShared(root)) return err("Vault is not shared. Run 'kib share ' first."); + const result = await pushVault(root, message); + return json(result); + } catch (e) { + return err((e as Error).message); + } + }, + ); + // ── Resources ───────────────────────────────────────────── server.resource("wiki-index", "wiki://index", { mimeType: "text/markdown" }, async () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7d26e62..dbe9a94 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,6 +54,24 @@ export * from "./schemas.js"; export { highlightSnippet, parseQuery, SearchIndex } from "./search/engine.js"; export { HybridSearch } from "./search/hybrid.js"; export { VectorIndex } from "./search/vector.js"; +export type { + Contributor, + PullResult, + PushResult, + ShareResult, + ShareStatus, +} from "./share.js"; +export { + cloneVault, + getContributor, + isGitRepo, + isShared, + mergeManifests, + pullVault, + pushVault, + shareStatus, + shareVault, +} from "./share.js"; export { getBuiltinSkills } from "./skills/builtins.js"; export { getHookedSkills, runSkillHooks } from "./skills/hooks.js"; export { findSkill, loadSkills } from "./skills/loader.js"; diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 41217bf..e20d2ec 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -163,6 +163,14 @@ export const VaultConfigSchema = z.object({ config: z.record(z.string(), z.record(z.string(), z.unknown())).default({}), }) .default({}), + sharing: z + .object({ + enabled: z.boolean().default(false), + remote: z.string().default(""), + auto_push: z.boolean().default(false), + auto_pull: z.boolean().default(false), + }) + .default({}), }); // ─── Article Frontmatter ───────────────────────────────────────── diff --git a/packages/core/src/share.test.ts b/packages/core/src/share.test.ts new file mode 100644 index 0000000..2c2040f --- /dev/null +++ b/packages/core/src/share.test.ts @@ -0,0 +1,348 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getContributor, isGitRepo, isShared, mergeManifests } from "./share.js"; +import type { Manifest } from "./types.js"; +import { initVault } from "./vault.js"; + +let tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs) { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } + tempDirs = []; +}); + +async function makeTempDir() { + const dir = await mkdtemp(join(tmpdir(), "kib-share-test-")); + tempDirs.push(dir); + return dir; +} + +async function _makeTempVault(name = "test-vault") { + const dir = await makeTempDir(); + await initVault(dir, { name }); + return dir; +} + +// ─── mergeManifests ───────────────────────────────────────────── + +describe("mergeManifests", () => { + const baseManifest: Manifest = { + version: "1", + vault: { + name: "test", + created: "2025-01-01T00:00:00.000Z", + lastCompiled: "2025-01-01T00:00:00.000Z", + provider: "anthropic", + model: "claude-sonnet-4-6", + }, + sources: { + src_aaa: { + hash: "aaa", + ingestedAt: "2025-01-01T00:00:00.000Z", + lastCompiled: "2025-01-01T00:00:00.000Z", + sourceType: "web", + producedArticles: ["article-a"], + status: "compiled", + metadata: { wordCount: 100 }, + }, + }, + articles: { + "article-a": { + hash: "ha", + createdAt: "2025-01-01T00:00:00.000Z", + lastUpdated: "2025-01-01T00:00:00.000Z", + derivedFrom: ["src_aaa"], + backlinks: [], + forwardLinks: [], + tags: ["test"], + summary: "Article A", + wordCount: 100, + category: "concept", + }, + }, + stats: { + totalSources: 1, + totalArticles: 1, + totalWords: 100, + lastLintAt: null, + }, + }; + + test("merges non-overlapping sources from both sides", () => { + const ours: Manifest = structuredClone(baseManifest); + ours.sources.src_bbb = { + hash: "bbb", + ingestedAt: "2025-01-02T00:00:00.000Z", + lastCompiled: null, + sourceType: "pdf", + producedArticles: [], + status: "ingested", + metadata: { wordCount: 200 }, + }; + + const theirs: Manifest = structuredClone(baseManifest); + theirs.sources.src_ccc = { + hash: "ccc", + ingestedAt: "2025-01-03T00:00:00.000Z", + lastCompiled: null, + sourceType: "youtube", + producedArticles: [], + status: "ingested", + metadata: { wordCount: 300 }, + }; + + const merged = mergeManifests(baseManifest, ours, theirs); + + expect(Object.keys(merged.sources)).toHaveLength(3); + expect(merged.sources.src_aaa).toBeDefined(); + expect(merged.sources.src_bbb).toBeDefined(); + expect(merged.sources.src_ccc).toBeDefined(); + expect(merged.stats.totalSources).toBe(3); + }); + + test("merges non-overlapping articles from both sides", () => { + const ours: Manifest = structuredClone(baseManifest); + ours.articles["article-b"] = { + hash: "hb", + createdAt: "2025-01-02T00:00:00.000Z", + lastUpdated: "2025-01-02T00:00:00.000Z", + derivedFrom: ["src_aaa"], + backlinks: [], + forwardLinks: [], + tags: [], + summary: "Article B", + wordCount: 200, + category: "topic", + }; + + const theirs: Manifest = structuredClone(baseManifest); + theirs.articles["article-c"] = { + hash: "hc", + createdAt: "2025-01-03T00:00:00.000Z", + lastUpdated: "2025-01-03T00:00:00.000Z", + derivedFrom: ["src_aaa"], + backlinks: [], + forwardLinks: [], + tags: [], + summary: "Article C", + wordCount: 150, + category: "reference", + }; + + const merged = mergeManifests(baseManifest, ours, theirs); + + expect(Object.keys(merged.articles)).toHaveLength(3); + expect(merged.stats.totalArticles).toBe(3); + expect(merged.stats.totalWords).toBe(450); // 100 + 200 + 150 + }); + + test("prefers newer article on conflict", () => { + const ours: Manifest = structuredClone(baseManifest); + ours.articles["article-a"]!.lastUpdated = "2025-01-05T00:00:00.000Z"; + ours.articles["article-a"]!.summary = "Updated by Alice"; + ours.articles["article-a"]!.wordCount = 150; + + const theirs: Manifest = structuredClone(baseManifest); + theirs.articles["article-a"]!.lastUpdated = "2025-01-03T00:00:00.000Z"; + theirs.articles["article-a"]!.summary = "Updated by Bob"; + + const merged = mergeManifests(baseManifest, ours, theirs); + + expect(merged.articles["article-a"]!.summary).toBe("Updated by Alice"); + expect(merged.articles["article-a"]!.wordCount).toBe(150); + }); + + test("prefers theirs when theirs is newer", () => { + const ours: Manifest = structuredClone(baseManifest); + ours.articles["article-a"]!.lastUpdated = "2025-01-02T00:00:00.000Z"; + ours.articles["article-a"]!.summary = "Older edit"; + + const theirs: Manifest = structuredClone(baseManifest); + theirs.articles["article-a"]!.lastUpdated = "2025-01-10T00:00:00.000Z"; + theirs.articles["article-a"]!.summary = "Newer edit"; + + const merged = mergeManifests(baseManifest, ours, theirs); + + expect(merged.articles["article-a"]!.summary).toBe("Newer edit"); + }); + + test("prefers newer source on conflict", () => { + const ours: Manifest = structuredClone(baseManifest); + ours.sources.src_aaa!.ingestedAt = "2025-01-05T00:00:00.000Z"; + ours.sources.src_aaa!.status = "compiled"; + + const theirs: Manifest = structuredClone(baseManifest); + theirs.sources.src_aaa!.ingestedAt = "2025-01-02T00:00:00.000Z"; + + const merged = mergeManifests(baseManifest, ours, theirs); + + expect(merged.sources.src_aaa!.ingestedAt).toBe("2025-01-05T00:00:00.000Z"); + }); + + test("takes the most recent lastCompiled for vault", () => { + const ours: Manifest = structuredClone(baseManifest); + ours.vault.lastCompiled = "2025-01-10T00:00:00.000Z"; + + const theirs: Manifest = structuredClone(baseManifest); + theirs.vault.lastCompiled = "2025-01-15T00:00:00.000Z"; + + const merged = mergeManifests(baseManifest, ours, theirs); + + expect(merged.vault.lastCompiled).toBe("2025-01-15T00:00:00.000Z"); + }); + + test("preserves source when present in both sides", () => { + const base: Manifest = structuredClone(baseManifest); + base.sources.src_keep = { + hash: "keep", + ingestedAt: "2025-01-01T00:00:00.000Z", + lastCompiled: null, + sourceType: "file", + producedArticles: [], + status: "ingested", + metadata: { wordCount: 50 }, + }; + + const ours: Manifest = structuredClone(base); + const theirs: Manifest = structuredClone(base); + + const merged = mergeManifests(base, ours, theirs); + expect(merged.sources.src_keep).toBeDefined(); + }); + + test("recomputes stats correctly after merge", () => { + const ours: Manifest = structuredClone(baseManifest); + ours.sources.src_new = { + hash: "new", + ingestedAt: "2025-02-01T00:00:00.000Z", + lastCompiled: null, + sourceType: "web", + producedArticles: [], + status: "ingested", + metadata: { wordCount: 500 }, + }; + ours.articles["new-article"] = { + hash: "hn", + createdAt: "2025-02-01T00:00:00.000Z", + lastUpdated: "2025-02-01T00:00:00.000Z", + derivedFrom: ["src_new"], + backlinks: [], + forwardLinks: [], + tags: [], + summary: "New", + wordCount: 300, + category: "topic", + }; + + const merged = mergeManifests(baseManifest, ours, baseManifest); + + expect(merged.stats.totalSources).toBe(2); + expect(merged.stats.totalArticles).toBe(2); + expect(merged.stats.totalWords).toBe(400); // 100 + 300 + }); + + test("merges lastLintAt taking the most recent", () => { + const ours: Manifest = structuredClone(baseManifest); + ours.stats.lastLintAt = "2025-01-05T00:00:00.000Z"; + + const theirs: Manifest = structuredClone(baseManifest); + theirs.stats.lastLintAt = "2025-01-10T00:00:00.000Z"; + + const merged = mergeManifests(baseManifest, ours, theirs); + expect(merged.stats.lastLintAt).toBe("2025-01-10T00:00:00.000Z"); + }); + + test("handles both sides adding same source (dedup by key)", () => { + const ours: Manifest = structuredClone(baseManifest); + ours.sources.src_same = { + hash: "same", + ingestedAt: "2025-01-02T00:00:00.000Z", + lastCompiled: null, + sourceType: "web", + producedArticles: [], + status: "ingested", + metadata: { wordCount: 100 }, + }; + + const theirs: Manifest = structuredClone(baseManifest); + theirs.sources.src_same = { + hash: "same", + ingestedAt: "2025-01-02T00:00:00.000Z", + lastCompiled: null, + sourceType: "web", + producedArticles: [], + status: "ingested", + metadata: { wordCount: 100 }, + }; + + const merged = mergeManifests(baseManifest, ours, theirs); + expect(Object.keys(merged.sources)).toHaveLength(2); // original + one copy of same + }); + + test("handles null lastCompiled in vault", () => { + const base: Manifest = structuredClone(baseManifest); + base.vault.lastCompiled = null; + + const ours: Manifest = structuredClone(base); + ours.vault.lastCompiled = null; + + const theirs: Manifest = structuredClone(base); + theirs.vault.lastCompiled = "2025-01-15T00:00:00.000Z"; + + const merged = mergeManifests(base, ours, theirs); + expect(merged.vault.lastCompiled).toBe("2025-01-15T00:00:00.000Z"); + }); + + test("handles empty manifests", () => { + const empty: Manifest = { + version: "1", + vault: { + name: "empty", + created: "2025-01-01T00:00:00.000Z", + lastCompiled: null, + provider: "anthropic", + model: "claude-sonnet-4-6", + }, + sources: {}, + articles: {}, + stats: { totalSources: 0, totalArticles: 0, totalWords: 0, lastLintAt: null }, + }; + + const merged = mergeManifests(empty, empty, empty); + expect(Object.keys(merged.sources)).toHaveLength(0); + expect(Object.keys(merged.articles)).toHaveLength(0); + expect(merged.stats.totalWords).toBe(0); + }); +}); + +// ─── Utility Functions ────────────────────────────────────────── + +describe("isGitRepo", () => { + test("returns false for a regular directory", async () => { + const dir = await makeTempDir(); + expect(isGitRepo(dir)).toBe(false); + }); + + test("returns false for non-existent path", () => { + expect(isGitRepo(`/tmp/nonexistent-kib-test-${Date.now()}`)).toBe(false); + }); +}); + +describe("isShared", () => { + test("returns false for non-git directory", async () => { + const dir = await makeTempDir(); + expect(isShared(dir)).toBe(false); + }); +}); + +describe("getContributor", () => { + test("returns a name and email", () => { + const c = getContributor(); + expect(c.name).toBeTruthy(); + expect(typeof c.email).toBe("string"); + }); +}); diff --git a/packages/core/src/share.ts b/packages/core/src/share.ts new file mode 100644 index 0000000..9b69278 --- /dev/null +++ b/packages/core/src/share.ts @@ -0,0 +1,515 @@ +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { VAULT_DIR } from "./constants.js"; +import type { Manifest } from "./types.js"; +import { loadConfig, loadManifest, saveConfig, saveManifest } from "./vault.js"; + +// ─── Types ────────────────────────────────────────────────────── + +export interface ShareResult { + remote: string; + branch: string; +} + +export interface PullResult { + updated: boolean; + summary: string; + newSources: number; + newArticles: number; + conflicts: string[]; +} + +export interface PushResult { + pushed: boolean; + commit: string; + filesChanged: number; +} + +export interface ShareStatus { + shared: boolean; + remote?: string; + branch?: string; + ahead: number; + behind: number; + dirty: boolean; + lastSync?: string; + contributors: Contributor[]; +} + +export interface Contributor { + name: string; + email: string; + commits: number; + lastActive: string; +} + +// ─── Git Helpers ──────────────────────────────────────────────── + +function git(root: string, args: string): string { + return execSync(`git -c commit.gpgsign=false ${args}`, { + cwd: root, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); +} + +function gitOk(root: string, args: string): { ok: boolean; output: string } { + try { + return { ok: true, output: git(root, args) }; + } catch (e) { + return { ok: false, output: (e as Error).message }; + } +} + +export function isGitRepo(root: string): boolean { + return existsSync(join(root, ".git")); +} + +export function isShared(root: string): boolean { + if (!isGitRepo(root)) return false; + const { ok, output } = gitOk(root, "remote"); + return ok && output.length > 0; +} + +function getCurrentBranch(root: string): string { + try { + return git(root, "rev-parse --abbrev-ref HEAD"); + } catch { + return "main"; + } +} + +function getRemoteUrl(root: string): string | undefined { + const { ok, output } = gitOk(root, "remote get-url origin"); + return ok ? output : undefined; +} + +export function getContributor(): { name: string; email: string } { + try { + const name = execSync("git config user.name", { encoding: "utf-8" }).trim(); + const email = execSync("git config user.email", { encoding: "utf-8" }).trim(); + return { name: name || "unknown", email: email || "" }; + } catch { + return { name: require("node:os").userInfo().username, email: "" }; + } +} + +// ─── .gitignore ───────────────────────────────────────────────── + +const VAULT_GITIGNORE = `# kib — machine-local state (not shared) +.kb/vault.lock +.kb/cache/ +.kb/logs/ +.kb/backups/ +.kb/pipeline.db +.kb/pipeline.db-journal +.kb/pipeline.db-wal +`; + +async function ensureGitignore(root: string): Promise { + const gitignorePath = join(root, ".gitignore"); + if (!existsSync(gitignorePath)) { + await writeFile(gitignorePath, VAULT_GITIGNORE, "utf-8"); + return; + } + const existing = await readFile(gitignorePath, "utf-8"); + if (!existing.includes(".kb/vault.lock")) { + await appendFile(gitignorePath, `\n${VAULT_GITIGNORE}`, "utf-8"); + } +} + +// ─── Share (one-time setup) ───────────────────────────────────── + +export async function shareVault(root: string, remoteUrl: string): Promise { + // 1. Init git if needed + if (!isGitRepo(root)) { + git(root, "init -b main"); + } + + // 2. .gitignore + await ensureGitignore(root); + + // 3. Set remote + const { ok } = gitOk(root, "remote get-url origin"); + if (ok) { + git(root, `remote set-url origin ${remoteUrl}`); + } else { + git(root, `remote add origin ${remoteUrl}`); + } + + // 4. Update config + const config = await loadConfig(root); + (config as Record).sharing = { + enabled: true, + remote: remoteUrl, + auto_push: false, + auto_pull: false, + }; + await saveConfig(root, config); + + // 5. Initial commit + git(root, "add -A"); + const { ok: clean } = gitOk(root, "diff --cached --quiet"); + if (!clean) { + git(root, 'commit -m "feat: initialize shared kib vault"'); + } + + // 6. Push — handle empty remote or existing remote + const branch = getCurrentBranch(root); + try { + git(root, `push -u origin ${branch}`); + } catch { + // Remote might have content (e.g., GitHub created with README) + // Pull first, then push + gitOk(root, `pull origin ${branch} --rebase --allow-unrelated-histories`); + git(root, `push -u origin ${branch}`); + } + + return { remote: remoteUrl, branch }; +} + +// ─── Clone (join a shared vault) ──────────────────────────────── + +export async function cloneVault(remoteUrl: string, targetDir: string): Promise { + // Clone + execSync(`git -c commit.gpgsign=false clone ${remoteUrl} "${targetDir}"`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + + // Validate it's a kib vault + const kbDir = join(targetDir, VAULT_DIR); + if (!existsSync(kbDir)) { + throw new Error("Remote repository is not a kib vault (missing .kb/ directory)"); + } + + // Ensure machine-local directories exist + const localDirs = ["cache", "cache/responses", "logs", "backups"]; + await Promise.all(localDirs.map((d) => mkdir(join(kbDir, d), { recursive: true }))); + + return targetDir; +} + +// ─── Pull ─────────────────────────────────────────────────────── + +export async function pullVault(root: string): Promise { + if (!isGitRepo(root) || !isShared(root)) { + throw new Error("Vault is not shared. Run kib share first."); + } + + // Snapshot current state for diff + const beforeManifest = await loadManifest(root); + const beforeSources = new Set(Object.keys(beforeManifest.sources)); + const beforeArticles = new Set(Object.keys(beforeManifest.articles)); + + const branch = getCurrentBranch(root); + + // Fetch + git(root, `fetch origin ${branch}`); + + // Check if there are changes + const { ok: upToDate, output: diffOutput } = gitOk(root, `diff HEAD origin/${branch} --stat`); + if (upToDate && !diffOutput) { + return { + updated: false, + summary: "Already up to date.", + newSources: 0, + newArticles: 0, + conflicts: [], + }; + } + + // Stash local changes if dirty + const { ok: isClean } = gitOk(root, "diff --quiet HEAD"); + const stashed = !isClean; + if (stashed) { + git(root, "stash"); + } + + // Merge + const conflicts: string[] = []; + try { + git(root, `merge origin/${branch} --no-edit`); + } catch { + // Conflicts — try to resolve manifest automatically + const { ok: manifestConflict } = gitOk(root, "diff --name-only --diff-filter=U"); + + if (manifestConflict) { + const conflictedFiles = git(root, "diff --name-only --diff-filter=U") + .split("\n") + .filter(Boolean); + + for (const file of conflictedFiles) { + if (file === ".kb/manifest.json") { + // Auto-resolve manifest via 3-way merge + try { + const base = JSON.parse(git(root, `show :1:${file}`)) as Manifest; + const ours = JSON.parse(git(root, `show :2:${file}`)) as Manifest; + const theirs = JSON.parse(git(root, `show :3:${file}`)) as Manifest; + const merged = mergeManifests(base, ours, theirs); + await saveManifest(root, merged); + git(root, `add ${file}`); + } catch { + conflicts.push(file); + } + } else if ( + file.startsWith("wiki/") && + file !== "wiki/INDEX.md" && + file !== "wiki/GRAPH.md" + ) { + // Wiki articles — take the newer version + try { + const theirs = git(root, `show :3:${file}`); + await writeFile(join(root, file), theirs, "utf-8"); + git(root, `add ${file}`); + } catch { + conflicts.push(file); + } + } else if (file === "wiki/INDEX.md" || file === "wiki/GRAPH.md" || file === "wiki/LOG.md") { + // Generated files — take theirs, will be regenerated + try { + const theirs = git(root, `show :3:${file}`); + await writeFile(join(root, file), theirs, "utf-8"); + git(root, `add ${file}`); + } catch { + conflicts.push(file); + } + } else { + conflicts.push(file); + } + } + + if (conflicts.length === 0) { + git(root, 'commit --no-edit -m "merge: auto-resolve shared vault sync"'); + } + } + } + + // Unstash if we stashed + if (stashed) { + gitOk(root, "stash pop"); + } + + // Compute what's new + const afterManifest = await loadManifest(root); + const newSources = Object.keys(afterManifest.sources).filter((s) => !beforeSources.has(s)).length; + const newArticles = Object.keys(afterManifest.articles).filter( + (a) => !beforeArticles.has(a), + ).length; + + const parts: string[] = []; + if (newSources > 0) parts.push(`${newSources} new source${newSources === 1 ? "" : "s"}`); + if (newArticles > 0) parts.push(`${newArticles} new article${newArticles === 1 ? "" : "s"}`); + if (conflicts.length > 0) + parts.push(`${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}`); + const summary = parts.length > 0 ? parts.join(", ") : "Updated."; + + return { updated: true, summary, newSources, newArticles, conflicts }; +} + +// ─── Push ─────────────────────────────────────────────────────── + +export async function pushVault(root: string, message?: string): Promise { + if (!isGitRepo(root) || !isShared(root)) { + throw new Error("Vault is not shared. Run kib share first."); + } + + // Stage all shareable files + git(root, "add -A"); + + // Check if there's anything to commit + const { ok: clean } = gitOk(root, "diff --cached --quiet"); + if (clean) { + return { pushed: false, commit: "", filesChanged: 0 }; + } + + // Build a commit message from what changed + const commitMsg = message ?? generateCommitMessage(root); + git(root, `commit -m "${commitMsg.replace(/"/g, '\\"')}"`); + + const commit = git(root, "rev-parse --short HEAD"); + const filesChanged = Number.parseInt( + git(root, "diff --stat HEAD~1 --shortstat").match(/(\d+) file/)?.[1] ?? "0", + 10, + ); + + // Push + const branch = getCurrentBranch(root); + try { + git(root, `push origin ${branch}`); + } catch { + // Push failed — likely need to pull first + throw new Error("Push failed. Run kib pull first to sync with the remote."); + } + + return { pushed: true, commit, filesChanged }; +} + +function generateCommitMessage(root: string): string { + const staged = git(root, "diff --cached --name-only").split("\n").filter(Boolean); + + let sources = 0; + let articles = 0; + let config = false; + + for (const file of staged) { + if (file.startsWith("raw/")) sources++; + else if ( + file.startsWith("wiki/") && + !["wiki/INDEX.md", "wiki/GRAPH.md", "wiki/LOG.md"].includes(file) + ) + articles++; + else if (file.includes("config.toml")) config = true; + } + + const parts: string[] = []; + if (sources > 0) parts.push(`${sources} source${sources === 1 ? "" : "s"}`); + if (articles > 0) parts.push(`${articles} article${articles === 1 ? "" : "s"}`); + if (config) parts.push("config"); + + if (parts.length === 0) return "chore: sync vault"; + + const contributor = getContributor(); + return `sync: ${parts.join(", ")} [${contributor.name}]`; +} + +// ─── Status ───────────────────────────────────────────────────── + +export async function shareStatus(root: string): Promise { + if (!isGitRepo(root)) { + return { shared: false, ahead: 0, behind: 0, dirty: false, contributors: [] }; + } + + const remote = getRemoteUrl(root); + const branch = getCurrentBranch(root); + const shared = !!remote; + + let ahead = 0; + let behind = 0; + + if (shared) { + // Fetch silently to get latest counts + gitOk(root, `fetch origin ${branch}`); + + const { ok, output } = gitOk(root, `rev-list --left-right --count HEAD...origin/${branch}`); + if (ok && output) { + const [a, b] = output.split("\t").map(Number); + ahead = a ?? 0; + behind = b ?? 0; + } + } + + const { ok: isClean } = gitOk(root, "diff --quiet HEAD"); + const dirty = !isClean; + + // Get contributors from git log + const contributors: Contributor[] = []; + if (shared) { + const { ok: logOk, output: logOutput } = gitOk( + root, + 'log --format="%aN\t%aE\t%aI" --no-merges', + ); + if (logOk && logOutput) { + const seen = new Map(); + for (const line of logOutput.split("\n").filter(Boolean)) { + const [name, email, date] = line.split("\t"); + if (!name) continue; + const existing = seen.get(name); + if (existing) { + existing.commits++; + if (date && date > existing.lastActive) existing.lastActive = date; + } else { + seen.set(name, { email: email ?? "", commits: 1, lastActive: date ?? "" }); + } + } + for (const [name, data] of seen) { + contributors.push({ name, ...data }); + } + contributors.sort((a, b) => b.commits - a.commits); + } + } + + // Last sync = last push/pull timestamp + const { ok: lastSyncOk, output: lastSync } = gitOk( + root, + `log origin/${branch} -1 --format="%aI"`, + ); + + return { + shared, + remote, + branch, + ahead, + behind, + dirty, + lastSync: lastSyncOk && lastSync ? lastSync.replace(/"/g, "") : undefined, + contributors, + }; +} + +// ─── Manifest 3-Way Merge ─────────────────────────────────────── + +export function mergeManifests(base: Manifest, ours: Manifest, theirs: Manifest): Manifest { + // Merge sources by key (union, prefer newer) + const mergedSources = { ...base.sources }; + + for (const [id, entry] of Object.entries(ours.sources)) { + const baseEntry = base.sources[id]; + if (!baseEntry || entry.ingestedAt > baseEntry.ingestedAt) { + mergedSources[id] = entry; + } + } + for (const [id, entry] of Object.entries(theirs.sources)) { + const existing = mergedSources[id]; + if (!existing || entry.ingestedAt > existing.ingestedAt) { + mergedSources[id] = entry; + } + } + + // Merge articles by slug (union, prefer newer lastUpdated) + const mergedArticles = { ...base.articles }; + + for (const [slug, entry] of Object.entries(ours.articles)) { + const baseEntry = base.articles[slug]; + if (!baseEntry || entry.lastUpdated > baseEntry.lastUpdated) { + mergedArticles[slug] = entry; + } + } + for (const [slug, entry] of Object.entries(theirs.articles)) { + const existing = mergedArticles[slug]; + if (!existing || entry.lastUpdated > existing.lastUpdated) { + mergedArticles[slug] = entry; + } + } + + // Recompute stats + const totalWords = Object.values(mergedArticles).reduce((sum, a) => sum + a.wordCount, 0); + + // Vault metadata: take whichever was compiled more recently + const vault = { ...ours.vault }; + if ( + theirs.vault.lastCompiled && + (!vault.lastCompiled || theirs.vault.lastCompiled > vault.lastCompiled) + ) { + vault.lastCompiled = theirs.vault.lastCompiled; + } + + // Last lint: take the more recent + const lastLintAt = + [ours.stats.lastLintAt, theirs.stats.lastLintAt].filter(Boolean).sort().pop() ?? null; + + return { + version: ours.version, + vault, + sources: mergedSources, + articles: mergedArticles, + stats: { + totalSources: Object.keys(mergedSources).length, + totalArticles: Object.keys(mergedArticles).length, + totalWords, + lastLintAt, + }, + }; +} diff --git a/packages/dashboard/src/client/App.tsx b/packages/dashboard/src/client/App.tsx index c2cf948..3d97e14 100644 --- a/packages/dashboard/src/client/App.tsx +++ b/packages/dashboard/src/client/App.tsx @@ -7,6 +7,7 @@ import { QueryPage } from "./components/QueryPage.js"; import { SearchPage } from "./components/SearchPage.js"; import { type Page, Shell } from "./components/Shell.js"; import { StatusPage } from "./components/StatusPage.js"; +import { TeamPage } from "./components/TeamPage.js"; import { useEvents } from "./useEvents.js"; export function App() { @@ -36,6 +37,7 @@ export function App() { )} {page === "ingest" && } + {page === "team" && } ); } diff --git a/packages/dashboard/src/client/api.ts b/packages/dashboard/src/client/api.ts index 44f4818..20776d4 100644 --- a/packages/dashboard/src/client/api.ts +++ b/packages/dashboard/src/client/api.ts @@ -107,6 +107,31 @@ export interface CompileResult { articlesDeleted: number; } +export interface ShareStatus { + shared: boolean; + remote?: string; + branch?: string; + ahead: number; + behind: number; + dirty: boolean; + lastSync?: string; + contributors: { name: string; email: string; commits: number; lastActive: string }[]; +} + +export interface PullResult { + updated: boolean; + summary: string; + newSources: number; + newArticles: number; + conflicts: string[]; +} + +export interface PushResult { + pushed: boolean; + commit: string; + filesChanged: number; +} + // --- API Functions --- export const api = { @@ -202,4 +227,9 @@ export const api = { }, getGraph: () => get("/graph"), + + // Sharing + getShareStatus: () => get("/share/status"), + sharePull: () => post("/share/pull", {}), + sharePush: (message?: string) => post("/share/push", { message }), }; diff --git a/packages/dashboard/src/client/components/Shell.tsx b/packages/dashboard/src/client/components/Shell.tsx index 157f125..049c821 100644 --- a/packages/dashboard/src/client/components/Shell.tsx +++ b/packages/dashboard/src/client/components/Shell.tsx @@ -6,12 +6,13 @@ import { MessageSquare, Plus, Search, + Users, X, } from "lucide-react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import type { VaultEvent } from "../useEvents.js"; -export type Page = "status" | "browse" | "search" | "query" | "graph" | "ingest"; +export type Page = "status" | "browse" | "search" | "query" | "graph" | "ingest" | "team"; interface NavItem { page: Page; @@ -27,6 +28,7 @@ const NAV_ITEMS: NavItem[] = [ { page: "query", label: "Query", icon: MessageSquare, shortcut: "4" }, { page: "graph", label: "Graph", icon: Compass, shortcut: "5" }, { page: "ingest", label: "Ingest", icon: Plus, shortcut: "6" }, + { page: "team", label: "Team", icon: Users, shortcut: "7" }, ]; interface ShellProps { diff --git a/packages/dashboard/src/client/components/TeamPage.tsx b/packages/dashboard/src/client/components/TeamPage.tsx new file mode 100644 index 0000000..d1d2241 --- /dev/null +++ b/packages/dashboard/src/client/components/TeamPage.tsx @@ -0,0 +1,204 @@ +import { ArrowDown, ArrowUp, GitBranch, RefreshCw, Users } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { api, type PullResult, type PushResult, type ShareStatus } from "../api.js"; + +interface TeamPageProps { + revision: number; +} + +export function TeamPage({ revision }: TeamPageProps) { + const [status, setStatus] = useState(null); + const [syncing, setSyncing] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const fetchStatus = useCallback(() => { + api + .getShareStatus() + .then(setStatus) + .catch(() => {}); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: revision triggers re-fetch + useEffect(() => { + fetchStatus(); + }, [fetchStatus, revision]); + + const handlePull = async () => { + setSyncing(true); + setMessage(null); + setError(null); + try { + const result: PullResult = await api.sharePull(); + setMessage(result.updated ? result.summary : "Already up to date."); + fetchStatus(); + } catch (err) { + setError((err as Error).message); + } finally { + setSyncing(false); + } + }; + + const handlePush = async () => { + setSyncing(true); + setMessage(null); + setError(null); + try { + const result: PushResult = await api.sharePush(); + setMessage( + result.pushed + ? `Pushed ${result.filesChanged} file${result.filesChanged === 1 ? "" : "s"}` + : "Nothing to push.", + ); + fetchStatus(); + } catch (err) { + setError((err as Error).message); + } finally { + setSyncing(false); + } + }; + + const handleSync = async () => { + setSyncing(true); + setMessage(null); + setError(null); + try { + const pullResult = await api.sharePull(); + const pushResult = await api.sharePush(); + const parts: string[] = []; + if (pullResult.updated) parts.push(pullResult.summary); + if (pushResult.pushed) + parts.push( + `pushed ${pushResult.filesChanged} file${pushResult.filesChanged === 1 ? "" : "s"}`, + ); + setMessage(parts.length > 0 ? parts.join(" · ") : "Already in sync."); + fetchStatus(); + } catch (err) { + setError((err as Error).message); + } finally { + setSyncing(false); + } + }; + + if (!status) { + return ( +
+
Loading...
+
+ ); + } + + if (!status.shared) { + return ( +
+

Team

+
+

This vault is not shared yet.

+

+ kib share <remote-url> +

+
+
+ ); + } + + return ( +
+

Team

+ + {/* Sync status card */} +
+
+
+ + {status.branch} + · + {status.remote} +
+ +
+ +
+ + + +
+ + {message && ( +
{message}
+ )} + {error && ( +
{error}
+ )} +
+ + {/* Contributors */} + {status.contributors.length > 0 && ( +
+
+ + Contributors +
+
+ {status.contributors.map((c) => ( +
+
+
{c.name}
+ {c.email &&
{c.email}
} +
+
+ {c.commits} commit{c.commits === 1 ? "" : "s"} +
+
+ ))} +
+
+ )} +
+ ); +} + +function SyncBadge({ ahead, behind, dirty }: { ahead: number; behind: number; dirty: boolean }) { + if (ahead === 0 && behind === 0 && !dirty) { + return ( + + in sync + + ); + } + + const parts: string[] = []; + if (ahead > 0) parts.push(`${ahead}\u2191`); + if (behind > 0) parts.push(`${behind}\u2193`); + if (dirty) parts.push("modified"); + + return ( + + {parts.join(" · ")} + + ); +} diff --git a/packages/dashboard/src/server/api.ts b/packages/dashboard/src/server/api.ts index bd1048e..d09bdeb 100644 --- a/packages/dashboard/src/server/api.ts +++ b/packages/dashboard/src/server/api.ts @@ -6,10 +6,14 @@ import { compileVault, computeStats, ingestSource, + isShared, listRaw, listWiki, + pullVault, + pushVault, readRaw, readWiki, + shareStatus, } from "@kibhq/core"; import type { DashboardContext } from "./context.js"; import { emit, handleEventsStream } from "./events.js"; @@ -284,6 +288,34 @@ export async function handleApi(url: URL, req: Request, ctx: DashboardContext): return json({ nodes, edges }); } + // GET /api/share/status + if (req.method === "GET" && path === "/share/status") { + if (!isShared(ctx.root)) { + return json({ shared: false, ahead: 0, behind: 0, dirty: false, contributors: [] }); + } + const status = await shareStatus(ctx.root); + return json(status); + } + + // POST /api/share/pull + if (req.method === "POST" && path === "/share/pull") { + if (!isShared(ctx.root)) return error("Vault is not shared", 400); + const result = await pullVault(ctx.root); + if (result.updated) { + ctx.invalidateSearch(); + emit({ type: "search_invalidated" }); + } + return json(result); + } + + // POST /api/share/push + if (req.method === "POST" && path === "/share/push") { + if (!isShared(ctx.root)) return error("Vault is not shared", 400); + const body = (await req.json().catch(() => ({}))) as { message?: string }; + const result = await pushVault(ctx.root, body.message); + return json(result); + } + return error("Not found", 404); } catch (err) { console.error("API error:", err); diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 3631a65..e492f1a 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -19,7 +19,7 @@ export const metadata: Metadata = { metadataBase: new URL("https://kib.dev"), title: "kib — The Headless Knowledge Compiler", description: - "CLI-first, LLM-powered tool that turns raw sources into a structured, queryable markdown wiki. Ingest URLs, PDFs, YouTube, GitHub repos, images. Compile with AI. Search and query instantly. MCP server included.", + "CLI-first, LLM-powered tool that turns raw sources into a structured, queryable markdown wiki. Ingest URLs, PDFs, YouTube, GitHub repos, images. Compile with AI. Search and query instantly. Share vaults with your team. MCP server included.", keywords: [ "knowledge base", "knowledge compiler", @@ -78,8 +78,9 @@ const jsonLd = { "AI-compiled structured markdown wiki", "BM25 full-text search with English stemming", "RAG query with cited answers", - "MCP server with 8 tools for Claude Code, Cursor, Claude Desktop", + "MCP server with 14 tools for Claude Code, Cursor, Claude Desktop", "Chrome extension for one-click webpage saving", + "Shared workspaces with git-based team collaboration", "Homebrew installation support", "Plain markdown files, no database, no lock-in", ], diff --git a/packages/web/src/app/llms.txt/route.ts b/packages/web/src/app/llms.txt/route.ts index 381c8d6..e1f0b98 100644 --- a/packages/web/src/app/llms.txt/route.ts +++ b/packages/web/src/app/llms.txt/route.ts @@ -36,6 +36,8 @@ kib chat - Skills: Built-in and custom .ts skills with full vault/LLM/search access - Export: Markdown or HTML static site output - Chrome Extension: One-click webpage saving (coming soon to Chrome Web Store) +- Shared Workspaces: Team collaboration via git — kib share, kib pull, kib push, kib clone +- Web Dashboard: Local UI with search, graph visualization, query, ingest, compile, and team sync ## MCP Server Integration diff --git a/packages/web/src/components/features.tsx b/packages/web/src/components/features.tsx index 4c144a0..913dfd7 100644 --- a/packages/web/src/components/features.tsx +++ b/packages/web/src/components/features.tsx @@ -13,6 +13,11 @@ const features = [ title: "Chrome extension", detail: "Save any webpage with one click, auto-capture on dwell, history sync", }, + { + title: "Shared workspaces", + detail: + "Share vaults with your team via git. Pull, push, auto-merge — from CLI, web UI, or MCP", + }, { title: "No lock-in", detail: "Plain markdown files. Version with git. No database" }, ];