unfairly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # unfairly
2
+
3
+ CLI + stdio MCP for syncing Unfairly vaults to local markdown.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ npm install -g unfairly@latest
9
+ cd ~/path/to/your/repo-or-workspace
10
+ unfairly init # interactive: paste mcp_* token from app.unfairly.ai/dashboard/setup
11
+ unfairly pull # fetch all accessible vaults to .unfairly/
12
+ unfairly status # show modified-locally count per vault
13
+ # edit any .md file in .unfairly/{vault}/{section}/{slug}.md
14
+ unfairly push # broadcast back to cloud
15
+ ```
16
+
17
+ ## Commands
18
+
19
+ - `unfairly init` — set up `.unfairly/` in cwd, prompt for token, run first pull
20
+ - `unfairly pull [vault]` — paginated diff from cloud, write/update markdown files, propagate deletes
21
+ - `unfairly push [vault]` — send dirty files to cloud with stale-check; conflicts get `<<<<<<< local / >>>>>>> cloud` markers in-place
22
+ - `unfairly status` — per-vault last-pulled timestamp + modified-locally count
23
+ - `unfairly resolve <path>` — mark a conflicted file as resolved (after editing out merge markers)
24
+ - `unfairly watch` — opt-in fs watcher; debounced 5s auto-push on save
25
+ - `unfairly mcp` — stdio MCP server exposing `pull_vault`, `push_vault`, `sync_status` tools to agent hosts (Claude Code, Codex, cowork)
26
+
27
+ ## Local store layout
28
+
29
+ ```
30
+ .unfairly/
31
+ ├── manifest.json # vault list + per-doc sha map; atomic writes
32
+ ├── .gitignore # generated; ignores cache/
33
+ ├── unfairly-app/ # repo-bound vault, git-tracked
34
+ │ └── architecture/
35
+ │ └── system.md
36
+ └── cache/ # gitignored bucket for personal/sensitive vaults
37
+ └── hq/
38
+ └── people/
39
+ └── founders.md
40
+ ```
41
+
42
+ Per-vault git-tracking is server-side via `rooms.track_in_git`. Tracked vaults live at the root; untracked vaults live under `cache/`.
43
+
44
+ ## Design
45
+
46
+ See [`docs/superpowers/specs/2026-05-07-local-context-sync-design.md`](../docs/superpowers/specs/2026-05-07-local-context-sync-design.md) in the main repo for architecture, conflict resolution, and integration with the cloud `/api/mcp` endpoint.
@@ -0,0 +1,80 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline/promises";
4
+ import { stdin as input, stdout as output } from "node:process";
5
+ import { SyncClient } from "../sync/api.js";
6
+ import { emptyManifest, writeManifest } from "../sync/manifest.js";
7
+ import { getToken, setToken } from "../sync/credentials.js";
8
+ import { runPull } from "./pull.js";
9
+ const DEFAULT_API = "https://app.unfairly.ai";
10
+ export async function runInit() {
11
+ const cwd = process.cwd();
12
+ const target = path.join(cwd, ".unfairly");
13
+ // Confirm overwrite if .unfairly/ already exists
14
+ try {
15
+ await fs.access(target);
16
+ const rl = readline.createInterface({ input, output });
17
+ const ans = await rl.question(`.unfairly/ already exists. Re-initialize? [y/N] `);
18
+ rl.close();
19
+ if (ans.trim().toLowerCase() !== "y") {
20
+ console.log("aborted");
21
+ return;
22
+ }
23
+ }
24
+ catch {
25
+ // didn't exist; fine
26
+ }
27
+ // Token
28
+ let token = await getToken();
29
+ if (!token) {
30
+ const rl = readline.createInterface({ input, output });
31
+ console.log("Get a token at https://app.unfairly.ai/dashboard/setup (click 'Issue token').");
32
+ token = (await rl.question("Paste mcp_* token: ")).trim();
33
+ rl.close();
34
+ if (!token.startsWith("mcp_")) {
35
+ console.error("Token must start with mcp_");
36
+ process.exit(1);
37
+ }
38
+ await setToken(token);
39
+ }
40
+ // Verify token + list vaults
41
+ const apiUrl = process.env.UNFAIRLY_API_URL ?? DEFAULT_API;
42
+ const client = new SyncClient(apiUrl, token);
43
+ let status;
44
+ try {
45
+ status = await client.status();
46
+ }
47
+ catch (err) {
48
+ console.error(`Failed to reach ${apiUrl}/api/sync/status: ${err.message}`);
49
+ process.exit(1);
50
+ }
51
+ if (status.vaults.length === 0) {
52
+ console.error("No accessible vaults. Ask an admin to grant you access.");
53
+ process.exit(1);
54
+ }
55
+ console.log(`Found ${status.vaults.length} accessible vault(s):`);
56
+ for (const v of status.vaults) {
57
+ console.log(` - ${v.name} (${v.slug}) — ${v.doc_count} docs${v.track_in_git ? "" : " · gitignored"}`);
58
+ }
59
+ // Seed manifest
60
+ await fs.mkdir(target, { recursive: true });
61
+ const manifest = emptyManifest(apiUrl);
62
+ for (const v of status.vaults) {
63
+ manifest.vaults[v.slug] = {
64
+ id: v.id,
65
+ slug: v.slug,
66
+ name: v.name,
67
+ track_in_git: v.track_in_git,
68
+ last_pulled_at: null,
69
+ last_pushed_at: null,
70
+ docs: {},
71
+ };
72
+ }
73
+ await writeManifest(cwd, manifest);
74
+ // Generate .gitignore (cache/ is the gitignored bucket for non-tracked vaults)
75
+ const gitignorePath = path.join(target, ".gitignore");
76
+ await fs.writeFile(gitignorePath, "# generated by `unfairly init`\ncache/\n*.tmp.*\n", "utf8");
77
+ // First pull (all vaults)
78
+ await runPull(undefined);
79
+ console.log("Done. Run `unfairly status` to confirm. Edit any .md file under .unfairly/ and run `unfairly push` to broadcast.");
80
+ }
@@ -0,0 +1,85 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
+ import { z } from "zod";
5
+ import { runPull } from "./pull.js";
6
+ import { runPush } from "./push.js";
7
+ import { runStatus } from "./status.js";
8
+ const PullSchema = z.object({ room: z.string().optional() }).strict();
9
+ const PushSchema = z.object({ room: z.string().optional() }).strict();
10
+ const StatusSchema = z.object({}).strict();
11
+ export async function runMcp() {
12
+ const server = new Server({ name: "unfairly-sync", version: "0.1.0" }, { capabilities: { tools: {} } });
13
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
14
+ tools: [
15
+ {
16
+ name: "pull_vault",
17
+ description: "Sync local .unfairly/ from cloud. Pass `room` to scope to one vault, omit for all accessible vaults. Reports docs added/updated/deleted.",
18
+ inputSchema: {
19
+ type: "object",
20
+ properties: {
21
+ room: {
22
+ type: "string",
23
+ description: "Vault slug to scope to (optional)",
24
+ },
25
+ },
26
+ },
27
+ },
28
+ {
29
+ name: "push_vault",
30
+ description: "Push locally-modified markdown to cloud. Stale-check via expected_updated_at. Returns 409s for conflicts; CLI rewrites those files with merge markers.",
31
+ inputSchema: {
32
+ type: "object",
33
+ properties: {
34
+ room: {
35
+ type: "string",
36
+ description: "Vault slug to scope to (optional)",
37
+ },
38
+ },
39
+ },
40
+ },
41
+ {
42
+ name: "sync_status",
43
+ description: "Show local sync status: per-vault last-pulled timestamp + modified-locally count.",
44
+ inputSchema: { type: "object", properties: {} },
45
+ },
46
+ ],
47
+ }));
48
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
49
+ const { name, arguments: args } = req.params;
50
+ const captured = [];
51
+ const origLog = console.log;
52
+ const origWarn = console.warn;
53
+ const origError = console.error;
54
+ console.log = (...a) => captured.push(a.map(String).join(" "));
55
+ console.warn = (...a) => captured.push("! " + a.map(String).join(" "));
56
+ console.error = (...a) => captured.push("ERR " + a.map(String).join(" "));
57
+ try {
58
+ if (name === "pull_vault") {
59
+ const { room } = PullSchema.parse(args ?? {});
60
+ await runPull(room);
61
+ }
62
+ else if (name === "push_vault") {
63
+ const { room } = PushSchema.parse(args ?? {});
64
+ await runPush(room);
65
+ }
66
+ else if (name === "sync_status") {
67
+ StatusSchema.parse(args ?? {});
68
+ await runStatus();
69
+ }
70
+ else {
71
+ throw new Error(`unknown tool: ${name}`);
72
+ }
73
+ }
74
+ finally {
75
+ console.log = origLog;
76
+ console.warn = origWarn;
77
+ console.error = origError;
78
+ }
79
+ return {
80
+ content: [{ type: "text", text: captured.join("\n") || "ok" }],
81
+ };
82
+ });
83
+ const transport = new StdioServerTransport();
84
+ await server.connect(transport);
85
+ }
@@ -0,0 +1,98 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { SyncClient } from "../sync/api.js";
6
+ import { readManifest, writeManifest } from "../sync/manifest.js";
7
+ import { buildFileFromServer, writeDocFile } from "../sync/files.js";
8
+ import { getToken } from "../sync/credentials.js";
9
+ const execFileAsync = promisify(execFile);
10
+ async function gitMv(from, to) {
11
+ try {
12
+ await execFileAsync("git", ["mv", from, to], { cwd: process.cwd() });
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ async function tryUnlink(p) {
20
+ try {
21
+ await fs.unlink(p);
22
+ }
23
+ catch { }
24
+ }
25
+ export async function runPull(vaultArg) {
26
+ const cwd = process.cwd();
27
+ const manifest = await readManifest(cwd);
28
+ if (!manifest) {
29
+ console.error("No .unfairly/ found. Run `unfairly init` first.");
30
+ process.exit(1);
31
+ }
32
+ const token = await getToken();
33
+ if (!token) {
34
+ console.error("No token. Run `unfairly init` to authenticate.");
35
+ process.exit(1);
36
+ }
37
+ const client = new SyncClient(manifest.api_url, token);
38
+ const targets = vaultArg ? [vaultArg] : Object.keys(manifest.vaults);
39
+ for (const slug of targets) {
40
+ const v = manifest.vaults[slug];
41
+ if (!v) {
42
+ console.error(`No vault \`${slug}\` in manifest.`);
43
+ continue;
44
+ }
45
+ process.stdout.write(`pulling ${slug}... `);
46
+ const result = await client.pullAll(slug, v.last_pulled_at);
47
+ // Vault root: track_in_git → .unfairly/{slug}/, else .unfairly/cache/{slug}/
48
+ const vaultRoot = v.track_in_git ? slug : path.join("cache", slug);
49
+ const absVaultRoot = path.join(cwd, ".unfairly", vaultRoot);
50
+ await fs.mkdir(absVaultRoot, { recursive: true });
51
+ let upserts = 0;
52
+ let deletes = 0;
53
+ let renames = 0;
54
+ for (const doc of result.docs) {
55
+ const file = buildFileFromServer(doc, vaultRoot);
56
+ const relPath = file.path;
57
+ const absPath = path.join(cwd, ".unfairly", relPath);
58
+ // Detect rename: same id, different stored path
59
+ const prev = v.docs[doc.id];
60
+ if (prev && prev.path !== relPath) {
61
+ const prevAbs = path.join(cwd, ".unfairly", prev.path);
62
+ // Tracked vault → use git mv to preserve history
63
+ if (v.track_in_git) {
64
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
65
+ const ok = await gitMv(prevAbs, absPath);
66
+ if (!ok) {
67
+ await tryUnlink(prevAbs);
68
+ }
69
+ }
70
+ else {
71
+ await tryUnlink(prevAbs);
72
+ }
73
+ renames++;
74
+ }
75
+ await writeDocFile(absPath, file.content);
76
+ v.docs[doc.id] = {
77
+ slug: doc.slug,
78
+ path: relPath,
79
+ sha: file.sha,
80
+ updated_at: doc.updated_at,
81
+ source_type: doc.source_type ?? "markdown",
82
+ };
83
+ upserts++;
84
+ }
85
+ for (const tomb of result.deleted) {
86
+ const prev = v.docs[tomb.id];
87
+ if (!prev)
88
+ continue;
89
+ const absPath = path.join(cwd, ".unfairly", prev.path);
90
+ await tryUnlink(absPath);
91
+ delete v.docs[tomb.id];
92
+ deletes++;
93
+ }
94
+ v.last_pulled_at = new Date().toISOString();
95
+ console.log(`+${upserts} -${deletes}${renames ? ` ~${renames}` : ""}`);
96
+ }
97
+ await writeManifest(cwd, manifest);
98
+ }
@@ -0,0 +1,84 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { SyncClient } from "../sync/api.js";
4
+ import { readManifest, writeManifest } from "../sync/manifest.js";
5
+ import { computeSha, readDocFile, readDocRaw } from "../sync/files.js";
6
+ import { buildConflictFile } from "../sync/conflict.js";
7
+ import { getToken } from "../sync/credentials.js";
8
+ export async function runPush(vaultArg) {
9
+ const cwd = process.cwd();
10
+ const manifest = await readManifest(cwd);
11
+ if (!manifest) {
12
+ console.error("No .unfairly/ found. Run `unfairly init` first.");
13
+ process.exit(1);
14
+ }
15
+ const token = await getToken();
16
+ if (!token) {
17
+ console.error("No token. Run `unfairly init` to authenticate.");
18
+ process.exit(1);
19
+ }
20
+ const client = new SyncClient(manifest.api_url, token);
21
+ const targets = vaultArg ? [vaultArg] : Object.keys(manifest.vaults);
22
+ let conflicts = 0;
23
+ let pushed = 0;
24
+ for (const slug of targets) {
25
+ const v = manifest.vaults[slug];
26
+ if (!v) {
27
+ console.error(`No vault \`${slug}\` in manifest.`);
28
+ continue;
29
+ }
30
+ for (const [docId, entry] of Object.entries(v.docs)) {
31
+ const absPath = path.join(cwd, ".unfairly", entry.path);
32
+ let raw;
33
+ let local;
34
+ try {
35
+ raw = await readDocRaw(absPath);
36
+ local = await readDocFile(absPath);
37
+ }
38
+ catch {
39
+ // File missing locally — Phase 1 punt: skip (delete-locally not handled)
40
+ continue;
41
+ }
42
+ const currentSha = computeSha(raw);
43
+ if (currentSha === entry.sha)
44
+ continue; // not dirty
45
+ const result = await client.push({
46
+ vault: slug,
47
+ doc_id: docId,
48
+ expected_updated_at: entry.updated_at,
49
+ body: local.body,
50
+ client_frontmatter: local.frontmatter,
51
+ });
52
+ if (result.status === 200 && "ok" in result.data) {
53
+ const { updated_at, rejected_frontmatter } = result.data;
54
+ entry.sha = currentSha;
55
+ entry.updated_at = updated_at;
56
+ pushed++;
57
+ if (rejected_frontmatter.length > 0) {
58
+ console.warn(`! ${entry.path}: server kept ${rejected_frontmatter.map((r) => r.key).join(", ")} (your changes ignored)`);
59
+ }
60
+ }
61
+ else if (result.status === 409 && "server_body" in result.data) {
62
+ const d = result.data;
63
+ const conflictContent = buildConflictFile({
64
+ serverFrontmatter: d.server_frontmatter,
65
+ localBody: local.body,
66
+ serverBody: d.server_body,
67
+ serverUpdatedAt: d.server_updated_at,
68
+ });
69
+ await fs.writeFile(absPath, conflictContent, "utf8");
70
+ // Update entry's known updated_at to the server's so a resolve+push works
71
+ entry.updated_at = d.server_updated_at;
72
+ console.warn(`! conflict: ${entry.path} (server updated ${d.server_updated_at}). Edit to remove merge markers, then \`unfairly resolve <path>\` and \`unfairly push\`.`);
73
+ conflicts++;
74
+ }
75
+ else {
76
+ const err = "error" in result.data ? result.data.error : "unknown";
77
+ console.error(`push failed for ${entry.path}: ${err}`);
78
+ }
79
+ }
80
+ v.last_pushed_at = new Date().toISOString();
81
+ }
82
+ await writeManifest(cwd, manifest);
83
+ console.log(`pushed ${pushed}, conflicts ${conflicts}`);
84
+ }
@@ -0,0 +1,34 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { readManifest } from "../sync/manifest.js";
4
+ export async function runResolve(relOrAbsPath) {
5
+ const cwd = process.cwd();
6
+ const manifest = await readManifest(cwd);
7
+ if (!manifest) {
8
+ console.error("No .unfairly/ found.");
9
+ process.exit(1);
10
+ }
11
+ const abs = path.isAbsolute(relOrAbsPath)
12
+ ? relOrAbsPath
13
+ : path.join(cwd, relOrAbsPath);
14
+ const raw = await fs.readFile(abs, "utf8");
15
+ if (raw.includes("<<<<<<< local") || raw.includes(">>>>>>> cloud")) {
16
+ console.error("Merge markers still present. Edit the file to remove them, then re-run resolve.");
17
+ process.exit(1);
18
+ }
19
+ // Verify the file is in the manifest. We do NOT mutate entry.sha — leaving
20
+ // the pre-conflict sha lets `push` detect the resolved content as dirty
21
+ // and ship it. entry.updated_at was already advanced to server's during
22
+ // the conflict, so the next push's expected_updated_at will match.
23
+ const relPath = path.relative(path.join(cwd, ".unfairly"), abs);
24
+ for (const v of Object.values(manifest.vaults)) {
25
+ for (const [, entry] of Object.entries(v.docs)) {
26
+ if (entry.path === relPath) {
27
+ console.log(`markers cleared. Run \`unfairly push\` to broadcast.`);
28
+ return;
29
+ }
30
+ }
31
+ }
32
+ console.error("path not found in manifest");
33
+ process.exit(1);
34
+ }
@@ -0,0 +1,25 @@
1
+ import path from "node:path";
2
+ import { readManifest } from "../sync/manifest.js";
3
+ import { computeSha, readDocRaw } from "../sync/files.js";
4
+ export async function runStatus() {
5
+ const cwd = process.cwd();
6
+ const manifest = await readManifest(cwd);
7
+ if (!manifest) {
8
+ console.error("No .unfairly/ found. Run `unfairly init` first.");
9
+ process.exit(1);
10
+ }
11
+ for (const [slug, v] of Object.entries(manifest.vaults)) {
12
+ let modified = 0;
13
+ for (const [, entry] of Object.entries(v.docs)) {
14
+ try {
15
+ const raw = await readDocRaw(path.join(cwd, ".unfairly", entry.path));
16
+ if (computeSha(raw) !== entry.sha)
17
+ modified++;
18
+ }
19
+ catch { }
20
+ }
21
+ const pulled = v.last_pulled_at ?? "never";
22
+ const pushed = v.last_pushed_at ?? "never";
23
+ console.log(`${slug.padEnd(20)} pulled ${pulled} · pushed ${pushed} · modified ${modified}`);
24
+ }
25
+ }
@@ -0,0 +1,25 @@
1
+ import path from "node:path";
2
+ import chokidar from "chokidar";
3
+ import { runPush } from "./push.js";
4
+ export async function runWatch() {
5
+ const cwd = process.cwd();
6
+ const dir = path.join(cwd, ".unfairly");
7
+ console.log(`watching ${dir} (Ctrl+C to stop)`);
8
+ let timer = null;
9
+ const debouncePush = () => {
10
+ if (timer)
11
+ clearTimeout(timer);
12
+ timer = setTimeout(() => {
13
+ runPush(undefined).catch((e) => console.error("push failed:", e.message));
14
+ }, 5000);
15
+ };
16
+ chokidar
17
+ .watch(dir, {
18
+ ignored: [/(^|[\/\\])\../, /manifest\.json$/, /\.tmp\./],
19
+ ignoreInitial: true,
20
+ persistent: true,
21
+ })
22
+ .on("change", debouncePush)
23
+ .on("add", debouncePush);
24
+ await new Promise(() => { }); // keep alive until SIGINT
25
+ }
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import yargs from "yargs";
3
+ import { hideBin } from "yargs/helpers";
4
+ await yargs(hideBin(process.argv))
5
+ .scriptName("unfairly")
6
+ .usage("$0 <cmd> [args]")
7
+ .command("init", "Initialize .unfairly/ in cwd and pull all accessible vaults", () => { }, async () => {
8
+ const { runInit } = await import("./cmd/init.js");
9
+ await runInit();
10
+ })
11
+ .command("pull [vault]", "Pull markdown from cloud", (y) => y.positional("vault", { type: "string" }), async (argv) => {
12
+ const { runPull } = await import("./cmd/pull.js");
13
+ await runPull(argv.vault);
14
+ })
15
+ .command("push [vault]", "Push local edits to cloud", (y) => y.positional("vault", { type: "string" }), async (argv) => {
16
+ const { runPush } = await import("./cmd/push.js");
17
+ await runPush(argv.vault);
18
+ })
19
+ .command("status", "Show local-vs-cloud sync status", () => { }, async () => {
20
+ const { runStatus } = await import("./cmd/status.js");
21
+ await runStatus();
22
+ })
23
+ .command("resolve <path>", "Mark a conflicted file as resolved", (y) => y.positional("path", { type: "string", demandOption: true }), async (argv) => {
24
+ const { runResolve } = await import("./cmd/resolve.js");
25
+ await runResolve(argv.path);
26
+ })
27
+ .command("watch", "Watch .unfairly/ for changes and auto-push", () => { }, async () => {
28
+ const { runWatch } = await import("./cmd/watch.js");
29
+ await runWatch();
30
+ })
31
+ .command("mcp", "Run as a stdio MCP server (for agent host integration)", () => { }, async () => {
32
+ const { runMcp } = await import("./cmd/mcp.js");
33
+ await runMcp();
34
+ })
35
+ .demandCommand(1)
36
+ .strict()
37
+ .help()
38
+ .parse();
@@ -0,0 +1,54 @@
1
+ export class SyncClient {
2
+ apiUrl;
3
+ token;
4
+ constructor(apiUrl, token) {
5
+ this.apiUrl = apiUrl;
6
+ this.token = token;
7
+ }
8
+ headers() {
9
+ return {
10
+ Authorization: `Bearer ${this.token}`,
11
+ "Content-Type": "application/json",
12
+ };
13
+ }
14
+ async pull(vault, since) {
15
+ const url = new URL(`${this.apiUrl}/api/sync/pull`);
16
+ url.searchParams.set("vault", vault);
17
+ if (since)
18
+ url.searchParams.set("since", since);
19
+ const res = await fetch(url, { headers: this.headers() });
20
+ if (!res.ok) {
21
+ throw new Error(`pull failed: ${res.status} ${await res.text()}`);
22
+ }
23
+ return res.json();
24
+ }
25
+ /** Loop pull until has_more=false, merging pages. */
26
+ async pullAll(vault, since) {
27
+ const merged = await this.pull(vault, since);
28
+ while (merged.has_more && merged.next_cursor) {
29
+ const next = await this.pull(vault, merged.next_cursor);
30
+ merged.docs.push(...next.docs);
31
+ merged.deleted.push(...next.deleted);
32
+ merged.next_cursor = next.next_cursor;
33
+ merged.has_more = next.has_more;
34
+ }
35
+ return merged;
36
+ }
37
+ async push(args) {
38
+ const res = await fetch(`${this.apiUrl}/api/sync/push`, {
39
+ method: "POST",
40
+ headers: this.headers(),
41
+ body: JSON.stringify(args),
42
+ });
43
+ return { status: res.status, data: (await res.json()) };
44
+ }
45
+ async status() {
46
+ const res = await fetch(`${this.apiUrl}/api/sync/status`, {
47
+ headers: this.headers(),
48
+ });
49
+ if (!res.ok) {
50
+ throw new Error(`status failed: ${res.status} ${await res.text()}`);
51
+ }
52
+ return res.json();
53
+ }
54
+ }
@@ -0,0 +1,15 @@
1
+ import matter from "gray-matter";
2
+ /**
3
+ * Build a three-way merge file: server frontmatter (always wins for
4
+ * authoritative keys), then a conflict-marked body section. The user
5
+ * (or their agent) edits to remove markers, then runs `unfairly resolve`.
6
+ */
7
+ export function buildConflictFile(args) {
8
+ const conflictBody = `<<<<<<< local
9
+ ${args.localBody}
10
+ =======
11
+ ${args.serverBody}
12
+ >>>>>>> cloud (updated_at ${args.serverUpdatedAt})
13
+ `;
14
+ return matter.stringify(conflictBody, args.serverFrontmatter);
15
+ }
@@ -0,0 +1,50 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ const SERVICE = "unfairly";
5
+ const ACCOUNT = "default";
6
+ const FILE_FALLBACK = path.join(os.homedir(), ".unfairly", "credentials");
7
+ /**
8
+ * Try OS keychain via keytar; if unavailable (Linux without libsecret, CI,
9
+ * etc.), fall back to a 0600 file in ~/.unfairly/credentials.
10
+ */
11
+ export async function getToken() {
12
+ try {
13
+ const keytar = (await import("keytar")).default;
14
+ const t = await keytar.getPassword(SERVICE, ACCOUNT);
15
+ if (t)
16
+ return t;
17
+ }
18
+ catch {
19
+ // keytar unavailable; fall through
20
+ }
21
+ try {
22
+ return (await fs.readFile(FILE_FALLBACK, "utf8")).trim();
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ export async function setToken(token) {
29
+ try {
30
+ const keytar = (await import("keytar")).default;
31
+ await keytar.setPassword(SERVICE, ACCOUNT, token);
32
+ return;
33
+ }
34
+ catch {
35
+ // fall through
36
+ }
37
+ await fs.mkdir(path.dirname(FILE_FALLBACK), { recursive: true });
38
+ await fs.writeFile(FILE_FALLBACK, token, { mode: 0o600 });
39
+ }
40
+ export async function clearToken() {
41
+ try {
42
+ const keytar = (await import("keytar")).default;
43
+ await keytar.deletePassword(SERVICE, ACCOUNT);
44
+ }
45
+ catch { }
46
+ try {
47
+ await fs.unlink(FILE_FALLBACK);
48
+ }
49
+ catch { }
50
+ }
@@ -0,0 +1,70 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import matter from "gray-matter";
5
+ /**
6
+ * Sha of the canonical rendered file content. Pass the full markdown
7
+ * (frontmatter + body, as written to disk). Trailing whitespace is
8
+ * normalized so write→read round-trips don't trip false-dirty.
9
+ */
10
+ export function computeSha(rendered) {
11
+ const h = crypto.createHash("sha256");
12
+ h.update(rendered.replace(/\s+$/, ""));
13
+ return h.digest("hex").slice(0, 16);
14
+ }
15
+ function slugify(s) {
16
+ return s
17
+ .toLowerCase()
18
+ .replace(/\s+/g, "-")
19
+ .replace(/[^a-z0-9-]/g, "");
20
+ }
21
+ /** Build a relative path for a doc within a vault directory. */
22
+ export function docPath(vaultSlug, sectionName, slug) {
23
+ const section = slugify(sectionName ?? "_root");
24
+ return path.join(vaultSlug, section, `${slug}.md`);
25
+ }
26
+ /**
27
+ * Build the markdown file content for a server-side doc. The caller
28
+ * substitutes the vaultSlug into `path` after this returns; we leave it
29
+ * generic so the same builder works for both tracked and cached vault
30
+ * roots.
31
+ */
32
+ export function buildFileFromServer(doc, vaultRoot) {
33
+ const fm = {
34
+ unfairly_id: doc.id,
35
+ slug: doc.slug,
36
+ name: doc.name,
37
+ section: doc.sections?.name ?? null,
38
+ trust_tier: doc.trust_tier,
39
+ role: doc.role,
40
+ source_url: doc.source_url,
41
+ source_type: doc.source_type ?? "markdown",
42
+ updated_at: doc.updated_at,
43
+ };
44
+ const body = doc.markdown_content ?? "";
45
+ const content = matter.stringify(body, fm);
46
+ return {
47
+ path: docPath(vaultRoot, doc.sections?.name ?? null, doc.slug),
48
+ content,
49
+ sha: computeSha(content),
50
+ };
51
+ }
52
+ /** Read the raw file content (frontmatter + body, no parsing). */
53
+ export async function readDocRaw(absPath) {
54
+ return fs.readFile(absPath, "utf8");
55
+ }
56
+ export async function readDocFile(absPath) {
57
+ const raw = await fs.readFile(absPath, "utf8");
58
+ const parsed = matter(raw);
59
+ return {
60
+ frontmatter: parsed.data,
61
+ body: parsed.content,
62
+ };
63
+ }
64
+ /** Atomic write (tmp + rename). */
65
+ export async function writeDocFile(absPath, content) {
66
+ const tmp = `${absPath}.tmp.${process.pid}`;
67
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
68
+ await fs.writeFile(tmp, content, "utf8");
69
+ await fs.rename(tmp, absPath);
70
+ }
@@ -0,0 +1,36 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ const MANIFEST_FILE = "manifest.json";
4
+ export function manifestPath(rootDir) {
5
+ return path.join(rootDir, ".unfairly", MANIFEST_FILE);
6
+ }
7
+ export async function readManifest(rootDir) {
8
+ try {
9
+ const raw = await fs.readFile(manifestPath(rootDir), "utf8");
10
+ return JSON.parse(raw);
11
+ }
12
+ catch (err) {
13
+ if (err.code === "ENOENT")
14
+ return null;
15
+ throw err;
16
+ }
17
+ }
18
+ /**
19
+ * Atomic write: tmp file → fsync → rename. Rename is atomic on POSIX,
20
+ * preventing a half-written manifest if the process is killed mid-write.
21
+ */
22
+ export async function writeManifest(rootDir, m) {
23
+ const target = manifestPath(rootDir);
24
+ const tmp = `${target}.tmp.${process.pid}`;
25
+ await fs.mkdir(path.dirname(target), { recursive: true });
26
+ await fs.writeFile(tmp, JSON.stringify(m, null, 2), "utf8");
27
+ await fs.rename(tmp, target);
28
+ }
29
+ export function emptyManifest(apiUrl) {
30
+ return {
31
+ version: 1,
32
+ api_url: apiUrl,
33
+ vaults: {},
34
+ generated_at: new Date().toISOString(),
35
+ };
36
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "unfairly",
3
+ "version": "0.1.0",
4
+ "description": "CLI + stdio MCP for syncing Unfairly vaults to local markdown — the multiplayer context layer for AI agents",
5
+ "keywords": ["unfairly", "mcp", "ai", "context", "agents", "knowledge-base", "claude", "codex"],
6
+ "homepage": "https://unfairly.ai",
7
+ "repository": { "type": "git", "url": "git+https://github.com/Unfairly-AI/unfairly-app.git", "directory": "cli" },
8
+ "bugs": { "url": "https://github.com/Unfairly-AI/unfairly-app/issues" },
9
+ "license": "MIT",
10
+ "author": "Unfairly <bob@hq.unfairly.ai>",
11
+ "bin": { "unfairly": "dist/index.js" },
12
+ "type": "module",
13
+ "scripts": {
14
+ "build": "tsc && chmod +x dist/index.js",
15
+ "dev": "tsx src/index.ts",
16
+ "test": "vitest run",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.0.4",
21
+ "chokidar": "^4.0.1",
22
+ "gray-matter": "^4.0.3",
23
+ "keytar": "^7.9.0",
24
+ "yargs": "^17.7.2",
25
+ "zod": "^3.23.8"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^20.0.0",
29
+ "@types/yargs": "^17.0.33",
30
+ "tsx": "^4.19.2",
31
+ "typescript": "^5.6.0",
32
+ "vitest": "^2.1.0"
33
+ },
34
+ "engines": { "node": ">=20" },
35
+ "files": ["dist", "README.md"]
36
+ }