@knowalabs/knowa 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,45 @@
1
+ # @knowalabs/knowa
2
+
3
+ Sync your team's AI context — agents, commands, skills, rules, and docs — from
4
+ [Knowa](https://knowalabs.com) into the folders your AI tools actually read:
5
+
6
+ - `.claude/agents/`, `.claude/commands/`, `.claude/skills/` and a managed
7
+ team-rules block in `CLAUDE.md` (Claude Code)
8
+ - `.cursor/rules/knowa/`, `.cursor/commands/` (Cursor)
9
+ - `.knowa/docs/` (your team's docs as Markdown, for any tool)
10
+
11
+ Edit an agent in Knowa once; every teammate's repo picks it up on the next
12
+ `knowa pull` (or automatically under `knowa watch`).
13
+
14
+ ## Quick start
15
+
16
+ ```sh
17
+ npm install -g @knowalabs/knowa
18
+ knowa init # link this repo to a Knowa space (asks for an API key)
19
+ knowa pull # materialize the folders
20
+ knowa watch # keep them in sync while you work
21
+ ```
22
+
23
+ ## Commands
24
+
25
+ | Command | What it does |
26
+ | --- | --- |
27
+ | `knowa init` | Link the repo to a space; store your API key outside the repo |
28
+ | `knowa pull [--force] [--dry-run]` | Sync down. Never overwrites files you changed or created — conflicts are reported instead (exit code 2) |
29
+ | `knowa watch [--interval N]` | Poll for team changes (default 30s) and pull them |
30
+ | `knowa status [--porcelain]` | Show local edits and upstream changes |
31
+ | `knowa push [path…] [--org]` | Publish local asset edits back to Knowa; offers to adopt new untracked files in managed folders |
32
+
33
+ ## How syncing stays safe
34
+
35
+ - `knowa.json` (committed) holds the repo↔space link; your API key lives in
36
+ `KNOWA_API_KEY` or `~/.config/knowa/credentials.json` — never in the repo.
37
+ - `.knowa/state.json` (git-ignored) records what Knowa wrote. The CLI only
38
+ ever touches files it owns; anything you created or edited is left alone.
39
+ - Pushes are guarded by content hashes: if a teammate edited the same asset
40
+ since your last sync, you get a conflict instead of a silent overwrite.
41
+ - In `CLAUDE.md`, only the block between `<!-- knowa:begin -->` and
42
+ `<!-- knowa:end -->` is managed; the rest of the file is yours.
43
+
44
+ Requires Node 18+. Mint API keys in Knowa under **Settings → Organization →
45
+ API keys** (enable *write access* if you want `knowa push`).
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Managed rules block inside CLAUDE.md — the ONLY user-owned file Knowa
3
+ * touches, so editing is deliberately paranoid:
4
+ *
5
+ * - Only the region between the begin/end markers is ever rewritten.
6
+ * - Missing both markers → the block is appended (or the file created).
7
+ * - Exactly one begin AND one end marker, in order → rewrite between them.
8
+ * - Anything else (one marker missing, duplicated, out of order) → refuse
9
+ * with an error instead of guessing. The user repairs the markers by hand.
10
+ */
11
+ export const BEGIN_MARKER = "<!-- knowa:begin — managed by `knowa pull`, do not edit inside this block -->";
12
+ export const END_MARKER = "<!-- knowa:end -->";
13
+ /** Marker-only matching (any text after "knowa:begin" is tolerated). */
14
+ const BEGIN_RE = /<!--\s*knowa:begin[^>]*-->/g;
15
+ const END_RE = /<!--\s*knowa:end\s*-->/g;
16
+ /** Render the block's inner content (between the markers). */
17
+ export function renderRulesBlock(rules) {
18
+ if (rules.length === 0)
19
+ return "";
20
+ const sections = rules.map((r) => `## ${r.name}${r.scope === "org" ? "" : " (this project)"}\n\n${r.body.trim()}`);
21
+ return `# Team rules (synced from Knowa)\n\n${sections.join("\n\n")}`;
22
+ }
23
+ /**
24
+ * Insert or replace the managed block in a CLAUDE.md's content. `existing`
25
+ * is null when the file doesn't exist yet. An empty `block` removes the
26
+ * managed region (leaving user content untouched).
27
+ */
28
+ export function applyRulesBlock(existing, block) {
29
+ const wrapped = block === "" ? "" : `${BEGIN_MARKER}\n\n${block}\n\n${END_MARKER}`;
30
+ if (existing === null) {
31
+ if (wrapped === "")
32
+ return { ok: true, content: "", changed: false };
33
+ return { ok: true, content: wrapped + "\n", changed: true };
34
+ }
35
+ const begins = [...existing.matchAll(BEGIN_RE)];
36
+ const ends = [...existing.matchAll(END_RE)];
37
+ if (begins.length === 0 && ends.length === 0) {
38
+ if (wrapped === "")
39
+ return { ok: true, content: existing, changed: false };
40
+ const sep = existing === "" ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
41
+ return { ok: true, content: existing + sep + wrapped + "\n", changed: true };
42
+ }
43
+ if (begins.length !== 1 || ends.length !== 1) {
44
+ return {
45
+ ok: false,
46
+ error: "CLAUDE.md has duplicated knowa markers — expected exactly one knowa:begin and one knowa:end. Fix the markers (or delete the whole block) and re-run.",
47
+ };
48
+ }
49
+ const begin = begins[0];
50
+ const end = ends[0];
51
+ const beginStart = begin.index;
52
+ const endStop = end.index + end[0].length;
53
+ if (endStop < beginStart + begin[0].length) {
54
+ return {
55
+ ok: false,
56
+ error: "CLAUDE.md has knowa:end before knowa:begin. Fix the markers (or delete the whole block) and re-run.",
57
+ };
58
+ }
59
+ let replacement = wrapped;
60
+ let before = existing.slice(0, beginStart);
61
+ let after = existing.slice(endStop);
62
+ if (wrapped === "") {
63
+ // Removing the block: also collapse the blank lines that framed it.
64
+ before = before.replace(/\n+$/, before.trim() === "" ? "" : "\n");
65
+ after = after.replace(/^\n+/, "\n");
66
+ if (before === "")
67
+ after = after.replace(/^\n+/, "");
68
+ replacement = "";
69
+ }
70
+ const content = before + replacement + after;
71
+ return { ok: true, content, changed: content !== existing };
72
+ }
@@ -0,0 +1,89 @@
1
+ /** Claude Code folder conventions. RULEs go to CLAUDE.md via claude-md.ts. */
2
+ const claude = {
3
+ target: "claude",
4
+ mapAsset(asset) {
5
+ switch (asset.type) {
6
+ case "AGENT":
7
+ return `.claude/agents/${asset.slug}.md`;
8
+ case "COMMAND":
9
+ return `.claude/commands/${asset.slug}.md`;
10
+ case "SKILL":
11
+ return `.claude/skills/${asset.slug}/SKILL.md`;
12
+ case "RULE":
13
+ return null;
14
+ }
15
+ },
16
+ };
17
+ /**
18
+ * Cursor conventions. Rules live under a knowa/ subdirectory so tombstone
19
+ * deletion never has to touch user-authored rules. Cursor has no agent/skill
20
+ * equivalent — those are skipped.
21
+ */
22
+ const cursor = {
23
+ target: "cursor",
24
+ mapAsset(asset) {
25
+ switch (asset.type) {
26
+ case "RULE":
27
+ return `.cursor/rules/knowa/${asset.slug}.mdc`;
28
+ case "COMMAND":
29
+ return `.cursor/commands/${asset.slug}.md`;
30
+ default:
31
+ return null;
32
+ }
33
+ },
34
+ };
35
+ const docs = {
36
+ target: "docs",
37
+ mapDoc(doc) {
38
+ return `.knowa/docs/${doc.path}`;
39
+ },
40
+ };
41
+ const ADAPTERS = { claude, cursor, docs };
42
+ /**
43
+ * Manifest → the complete list of files the linked repo should contain.
44
+ * Space-scoped assets shadow org-global ones with the same (type, slug):
45
+ * the shadowed asset produces no files at all (both would map to the same
46
+ * path).
47
+ */
48
+ export function desiredFiles(manifest, targets) {
49
+ const shadowed = new Set(manifest.assets.filter((a) => a.shadows && !a.deletedAt).map((a) => a.shadows));
50
+ const files = [];
51
+ const seen = new Set();
52
+ for (const target of targets) {
53
+ const adapter = ADAPTERS[target];
54
+ if (adapter.mapAsset) {
55
+ for (const asset of manifest.assets) {
56
+ if (shadowed.has(asset.id))
57
+ continue;
58
+ const path = adapter.mapAsset(asset);
59
+ if (!path || seen.has(path))
60
+ continue;
61
+ seen.add(path);
62
+ files.push({
63
+ path,
64
+ kind: "asset",
65
+ id: asset.id,
66
+ remoteHash: asset.hash,
67
+ deleted: asset.deletedAt !== null,
68
+ });
69
+ }
70
+ }
71
+ if (adapter.mapDoc) {
72
+ for (const doc of manifest.docs) {
73
+ const path = adapter.mapDoc(doc);
74
+ if (!path || seen.has(path))
75
+ continue;
76
+ seen.add(path);
77
+ files.push({ path, kind: "doc", id: doc.id, remoteHash: doc.hash, deleted: false });
78
+ }
79
+ }
80
+ }
81
+ return files;
82
+ }
83
+ /** The live RULE assets that make up the managed CLAUDE.md block, org rules first. */
84
+ export function claudeRules(manifest) {
85
+ const shadowed = new Set(manifest.assets.filter((a) => a.shadows && !a.deletedAt).map((a) => a.shadows));
86
+ return manifest.assets
87
+ .filter((a) => a.type === "RULE" && !a.deletedAt && !shadowed.has(a.id))
88
+ .sort((a, b) => a.scope === b.scope ? a.slug.localeCompare(b.slug) : a.scope === "org" ? -1 : 1);
89
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/api.js ADDED
@@ -0,0 +1,87 @@
1
+ /** Typed client for the Knowa context API (see src/app/api/v1/context). */
2
+ export class ApiError extends Error {
3
+ status;
4
+ body;
5
+ constructor(status, message, body) {
6
+ super(message);
7
+ this.status = status;
8
+ this.body = body;
9
+ }
10
+ }
11
+ export class KnowaClient {
12
+ url;
13
+ apiKey;
14
+ constructor(url, apiKey) {
15
+ this.url = url;
16
+ this.apiKey = apiKey;
17
+ }
18
+ async request(path, init) {
19
+ const res = await fetch(`${this.url}${path}`, {
20
+ ...init,
21
+ headers: {
22
+ authorization: `Bearer ${this.apiKey}`,
23
+ ...(init?.body ? { "content-type": "application/json" } : {}),
24
+ ...(init?.etag ? { "if-none-match": init.etag } : {}),
25
+ ...init?.headers,
26
+ },
27
+ });
28
+ if (!res.ok && res.status !== 304) {
29
+ let body;
30
+ let message = `${res.status} ${res.statusText}`;
31
+ try {
32
+ body = await res.json();
33
+ const err = body;
34
+ if (err.message)
35
+ message = err.message;
36
+ else if (err.error)
37
+ message = err.error;
38
+ }
39
+ catch {
40
+ // Non-JSON error body — keep the status line.
41
+ }
42
+ throw new ApiError(res.status, message, body);
43
+ }
44
+ return res;
45
+ }
46
+ /** List spaces the key can read — also serves as key validation for init. */
47
+ async spaces() {
48
+ const res = await this.request("/api/v1/spaces");
49
+ const body = (await res.json());
50
+ return body.spaces;
51
+ }
52
+ /** Manifest, or null when the stored ETag still matches (nothing changed). */
53
+ async manifest(space, etag) {
54
+ const res = await this.request(`/api/v1/context/manifest?space=${encodeURIComponent(space)}`, {
55
+ etag,
56
+ });
57
+ if (res.status === 304)
58
+ return null;
59
+ return {
60
+ manifest: (await res.json()),
61
+ etag: res.headers.get("etag") ?? "",
62
+ };
63
+ }
64
+ async assets(space, ids) {
65
+ const res = await this.request(`/api/v1/context/assets?space=${encodeURIComponent(space)}&ids=${ids.map(encodeURIComponent).join(",")}`);
66
+ const body = (await res.json());
67
+ return body.assets;
68
+ }
69
+ async docMarkdown(pageId) {
70
+ const res = await this.request(`/api/v1/pages/${encodeURIComponent(pageId)}?format=md`);
71
+ return res.text();
72
+ }
73
+ async pushUpdate(id, baseHash, content) {
74
+ const res = await this.request(`/api/v1/context/assets/${encodeURIComponent(id)}`, {
75
+ method: "PUT",
76
+ body: JSON.stringify({ baseHash, content }),
77
+ });
78
+ return (await res.json());
79
+ }
80
+ async pushCreate(input) {
81
+ const res = await this.request(`/api/v1/context/assets`, {
82
+ method: "POST",
83
+ body: JSON.stringify(input),
84
+ });
85
+ return (await res.json());
86
+ }
87
+ }
@@ -0,0 +1,57 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { KnowaClient } from "../api.js";
5
+ import { ALL_TARGETS, CONFIG_FILENAME, DEFAULT_URL, resolveApiKey, storeApiKey, writeRepoConfig, } from "../config.js";
6
+ /**
7
+ * Link this repo to a Knowa space: validate a key, pick a space, write
8
+ * knowa.json (committed) + per-user credentials, and prepare .knowa/.
9
+ */
10
+ export async function runInit() {
11
+ const root = process.cwd();
12
+ if (existsSync(join(root, CONFIG_FILENAME))) {
13
+ console.log(`${CONFIG_FILENAME} already exists here — this repo is already linked.`);
14
+ console.log("Edit it directly, or delete it and re-run `knowa init`.");
15
+ return;
16
+ }
17
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
18
+ try {
19
+ const urlInput = (await rl.question(`Knowa URL [${DEFAULT_URL}]: `)).trim();
20
+ const url = (urlInput || DEFAULT_URL).replace(/\/+$/, "");
21
+ let apiKey = resolveApiKey(url);
22
+ if (apiKey) {
23
+ console.log("Using the API key from your environment/credentials.");
24
+ }
25
+ else {
26
+ apiKey = (await rl.question("API key (mint one at Settings → Organization → API keys): ")).trim();
27
+ if (!apiKey.startsWith("knowa_sk_")) {
28
+ throw new Error("That doesn't look like a Knowa key (expected knowa_sk_…).");
29
+ }
30
+ }
31
+ const client = new KnowaClient(url, apiKey);
32
+ const spaces = await client.spaces();
33
+ if (spaces.length === 0) {
34
+ throw new Error("This key can't see any spaces. Create a space in Knowa first.");
35
+ }
36
+ console.log("\nWhich space is this repository's project?");
37
+ spaces.forEach((s, i) => console.log(` ${i + 1}. ${s.name} (${s.key})`));
38
+ const answer = (await rl.question(`Space [1-${spaces.length}]: `)).trim();
39
+ const index = Number(answer) - 1;
40
+ const space = spaces[index];
41
+ if (!space)
42
+ throw new Error("Invalid selection.");
43
+ writeRepoConfig(root, { url, space: space.key, targets: [...ALL_TARGETS] });
44
+ storeApiKey(url, apiKey);
45
+ mkdirSync(join(root, ".knowa"), { recursive: true });
46
+ writeFileSync(join(root, ".knowa", ".gitignore"), "state.json\n");
47
+ console.log(`\nLinked to "${space.name}".`);
48
+ console.log(`- ${CONFIG_FILENAME} written — commit it so teammates share the link.`);
49
+ console.log("- Your API key is stored in ~/.config/knowa/credentials.json (never in the repo).");
50
+ console.log("- Synced outputs (.claude/, .cursor/, .knowa/docs/) are safe to commit;");
51
+ console.log(" .knowa/state.json is per-checkout and already git-ignored.");
52
+ console.log("\nNext: `knowa pull`");
53
+ }
54
+ finally {
55
+ rl.close();
56
+ }
57
+ }
@@ -0,0 +1,146 @@
1
+ import { claudeRules, desiredFiles } from "../adapters/index.js";
2
+ import { applyRulesBlock, renderRulesBlock } from "../adapters/claude-md.js";
3
+ import { bodyOf } from "../context.js";
4
+ import { deleteFileAndPrune, diskSnapshot, readFileOrNull, writeFileAtomic } from "../fsops.js";
5
+ import { sha256 } from "../hash.js";
6
+ import { planSync } from "../sync.js";
7
+ import { readState, writeState } from "../state.js";
8
+ const ASSET_BATCH = 50;
9
+ const DOC_CONCURRENCY = 5;
10
+ async function fetchAssets(ctx, ids) {
11
+ const byId = new Map();
12
+ for (let i = 0; i < ids.length; i += ASSET_BATCH) {
13
+ const batch = await ctx.client.assets(ctx.config.space, ids.slice(i, i + ASSET_BATCH));
14
+ for (const asset of batch)
15
+ byId.set(asset.id, asset);
16
+ }
17
+ return byId;
18
+ }
19
+ async function fetchDocs(ctx, ids) {
20
+ const byId = new Map();
21
+ for (let i = 0; i < ids.length; i += DOC_CONCURRENCY) {
22
+ const batch = ids.slice(i, i + DOC_CONCURRENCY);
23
+ const bodies = await Promise.all(batch.map((id) => ctx.client.docMarkdown(id)));
24
+ batch.forEach((id, idx) => byId.set(id, bodies[idx]));
25
+ }
26
+ return byId;
27
+ }
28
+ export async function runPull(ctx, opts = {}) {
29
+ const log = (msg) => {
30
+ if (!opts.quiet)
31
+ console.log(msg);
32
+ };
33
+ const state = readState(ctx.root);
34
+ const result = await ctx.client.manifest(ctx.config.space);
35
+ if (!result)
36
+ throw new Error("Unexpected 304 for an unconditional manifest request");
37
+ const { manifest, etag } = result;
38
+ const desired = desiredFiles(manifest, ctx.config.targets);
39
+ const plan = planSync(desired, state.files, diskSnapshot(ctx.root));
40
+ const conflicts = [];
41
+ const writes = [];
42
+ for (const action of plan.actions) {
43
+ if (action.op === "conflict-modified" || action.op === "conflict-untracked") {
44
+ if (opts.force) {
45
+ writes.push({ op: action.op === "conflict-untracked" ? "create" : "update", file: action.file });
46
+ }
47
+ else {
48
+ conflicts.push(action.path);
49
+ const why = action.op === "conflict-modified"
50
+ ? "locally modified — pull skipped it (use --force to overwrite, or `knowa push` to publish your edit)"
51
+ : "an untracked file already exists there — pull will never clobber it (use --force to adopt)";
52
+ log(`conflict ${action.path} (${why})`);
53
+ }
54
+ }
55
+ else {
56
+ writes.push(action);
57
+ }
58
+ }
59
+ const assetIds = new Set();
60
+ const docIds = new Set();
61
+ for (const action of writes) {
62
+ if (action.op === "create" || action.op === "update") {
63
+ (action.file.kind === "asset" ? assetIds : docIds).add(action.file.id);
64
+ }
65
+ }
66
+ // Rules render into CLAUDE.md — their bodies ride along in the same bulk fetch.
67
+ const rules = ctx.config.targets.includes("claude") ? claudeRules(manifest) : [];
68
+ for (const rule of rules)
69
+ assetIds.add(rule.id);
70
+ const [assetById, docById] = await Promise.all([
71
+ fetchAssets(ctx, [...assetIds]),
72
+ fetchDocs(ctx, [...docIds]),
73
+ ]);
74
+ // Next state: start from entries that carry forward (unchanged + still-conflicted).
75
+ const nextFiles = {};
76
+ for (const path of plan.unchanged)
77
+ nextFiles[path] = state.files[path];
78
+ for (const path of conflicts) {
79
+ if (state.files[path])
80
+ nextFiles[path] = state.files[path];
81
+ }
82
+ let applied = 0;
83
+ for (const action of writes) {
84
+ switch (action.op) {
85
+ case "create":
86
+ case "update": {
87
+ const { file } = action;
88
+ const content = file.kind === "asset" ? assetById.get(file.id)?.content : docById.get(file.id);
89
+ if (content === undefined) {
90
+ // Raced with a server-side delete between manifest and fetch — skip;
91
+ // the next pull's manifest will carry the tombstone.
92
+ continue;
93
+ }
94
+ if (!opts.dryRun)
95
+ writeFileAtomic(ctx.root, file.path, content);
96
+ nextFiles[file.path] = {
97
+ kind: file.kind,
98
+ id: file.id,
99
+ remoteHash: file.remoteHash,
100
+ fileHash: sha256(content),
101
+ ...(file.kind === "asset" ? { version: assetById.get(file.id)?.version } : {}),
102
+ };
103
+ applied++;
104
+ log(`${action.op === "create" ? "created " : "updated "} ${file.path}`);
105
+ break;
106
+ }
107
+ case "delete":
108
+ if (!opts.dryRun)
109
+ deleteFileAndPrune(ctx.root, action.path);
110
+ applied++;
111
+ log(`deleted ${action.path}`);
112
+ break;
113
+ case "orphan":
114
+ log(`orphaned ${action.path} (deleted upstream but locally modified — keeping your copy, no longer managed)`);
115
+ break;
116
+ }
117
+ }
118
+ const nextState = { files: nextFiles, claudeMd: state.claudeMd, etag };
119
+ // Managed CLAUDE.md rules block (claude target only).
120
+ if (ctx.config.targets.includes("claude")) {
121
+ const block = renderRulesBlock(rules.map((rule) => ({
122
+ name: rule.name,
123
+ scope: rule.scope,
124
+ body: bodyOf(assetById.get(rule.id)?.content ?? ""),
125
+ })));
126
+ const existing = readFileOrNull(ctx.root, "CLAUDE.md");
127
+ const applyResult = applyRulesBlock(existing, block);
128
+ if (!applyResult.ok) {
129
+ conflicts.push("CLAUDE.md");
130
+ log(`conflict CLAUDE.md (${applyResult.error})`);
131
+ }
132
+ else if (applyResult.changed) {
133
+ if (!opts.dryRun)
134
+ writeFileAtomic(ctx.root, "CLAUDE.md", applyResult.content);
135
+ applied++;
136
+ log(`updated CLAUDE.md (team rules block)`);
137
+ }
138
+ nextState.claudeMd = { blockHash: sha256(block), assetIds: rules.map((r) => r.id) };
139
+ }
140
+ if (!opts.dryRun)
141
+ writeState(ctx.root, nextState);
142
+ log(applied === 0 && conflicts.length === 0
143
+ ? "Already up to date."
144
+ : `Done: ${applied} file${applied === 1 ? "" : "s"} synced${conflicts.length ? `, ${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"} skipped` : ""}.`);
145
+ return { applied, conflicts, etag };
146
+ }
@@ -0,0 +1,157 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { ApiError } from "../api.js";
5
+ import { loadContext } from "../context.js";
6
+ import { sha256 } from "../hash.js";
7
+ import { readState, writeState } from "../state.js";
8
+ /**
9
+ * Untracked files inside the folders Knowa manages can be adopted as new
10
+ * assets — the path infers the type, the filename the slug.
11
+ */
12
+ function inferAsset(path) {
13
+ let m = /^\.claude\/agents\/([a-z0-9][a-z0-9-]*)\.md$/.exec(path);
14
+ if (m)
15
+ return { type: "AGENT", slug: m[1] };
16
+ m = /^\.claude\/commands\/([a-z0-9][a-z0-9-]*)\.md$/.exec(path);
17
+ if (m)
18
+ return { type: "COMMAND", slug: m[1] };
19
+ m = /^\.claude\/skills\/([a-z0-9][a-z0-9-]*)\/SKILL\.md$/.exec(path);
20
+ if (m)
21
+ return { type: "SKILL", slug: m[1] };
22
+ m = /^\.cursor\/rules\/knowa\/([a-z0-9][a-z0-9-]*)\.mdc$/.exec(path);
23
+ if (m)
24
+ return { type: "RULE", slug: m[1] };
25
+ return null;
26
+ }
27
+ function* managedDirFiles(root) {
28
+ const dirs = [".claude/agents", ".claude/commands", ".cursor/rules/knowa", ".cursor/commands"];
29
+ for (const dir of dirs) {
30
+ if (!existsSync(join(root, dir)))
31
+ continue;
32
+ for (const name of readdirSync(join(root, dir))) {
33
+ if (name.endsWith(".md") || name.endsWith(".mdc"))
34
+ yield `${dir}/${name}`;
35
+ }
36
+ }
37
+ const skills = join(root, ".claude/skills");
38
+ if (existsSync(skills)) {
39
+ for (const slug of readdirSync(skills)) {
40
+ const skillFile = `.claude/skills/${slug}/SKILL.md`;
41
+ if (existsSync(join(root, skillFile)))
42
+ yield skillFile;
43
+ }
44
+ }
45
+ }
46
+ /** Ensure pushed content parses server-side: minimal frontmatter if missing. */
47
+ function withFrontmatter(content, slug) {
48
+ return content.startsWith("---\n") ? content : `---\nname: ${slug}\n---\n\n${content}`;
49
+ }
50
+ export async function runPush(paths, opts = {}) {
51
+ const ctx = loadContext();
52
+ const state = readState(ctx.root);
53
+ const wanted = paths.length > 0 ? new Set(paths.map((p) => p.replace(/^\.\//, ""))) : null;
54
+ let failures = 0;
55
+ // 1. Tracked asset files with local edits → PUT with the 3-way base hash.
56
+ for (const [path, entry] of Object.entries(state.files)) {
57
+ if (wanted && !wanted.has(path))
58
+ continue;
59
+ if (entry.kind === "doc") {
60
+ if (wanted) {
61
+ console.log(`skip ${path} (docs are pull-only — edit them in the Knowa web app)`);
62
+ }
63
+ continue;
64
+ }
65
+ const abs = join(ctx.root, path);
66
+ if (!existsSync(abs))
67
+ continue;
68
+ const content = readFileSync(abs, "utf8");
69
+ if (sha256(content) === entry.fileHash)
70
+ continue;
71
+ try {
72
+ const res = await ctx.client.pushUpdate(entry.id, entry.remoteHash, content);
73
+ state.files[path] = { ...entry, remoteHash: res.hash, fileHash: sha256(content), version: res.version };
74
+ console.log(`pushed ${path} (now v${res.version})`);
75
+ }
76
+ catch (err) {
77
+ failures++;
78
+ if (err instanceof ApiError && err.status === 409) {
79
+ console.error(`conflict ${path} — remote changed since your last sync. Run \`knowa pull\`, resolve, then push again.`);
80
+ }
81
+ else if (err instanceof ApiError && err.status === 403) {
82
+ console.error(`denied ${path} — ${err.message}`);
83
+ }
84
+ else if (err instanceof ApiError && err.status === 422) {
85
+ console.error(`invalid ${path} — ${err.message}`);
86
+ }
87
+ else {
88
+ console.error(`error ${path} — ${err.message}`);
89
+ }
90
+ }
91
+ }
92
+ // 2. CLAUDE.md rules-block edits can't round-trip to individual rule assets.
93
+ if (state.claudeMd && existsSync(join(ctx.root, "CLAUDE.md"))) {
94
+ // Block extraction is fuzzy on hand-edited files; a cheap full-file check
95
+ // is enough to warn — the authoritative editor for rules is the web UI.
96
+ const wantsClaudeMd = !wanted || wanted.has("CLAUDE.md");
97
+ if (wantsClaudeMd && wanted) {
98
+ console.log("skip CLAUDE.md (team rules are edited in the Knowa web app, not pushed)");
99
+ }
100
+ }
101
+ // 3. Untracked files in managed folders → offer to create.
102
+ const untracked = [];
103
+ for (const path of managedDirFiles(ctx.root)) {
104
+ if (state.files[path])
105
+ continue;
106
+ if (wanted && !wanted.has(path))
107
+ continue;
108
+ const inferred = inferAsset(path);
109
+ if (inferred)
110
+ untracked.push({ path, ...inferred });
111
+ }
112
+ if (untracked.length > 0) {
113
+ const scope = opts.org ? "org" : "space";
114
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
115
+ try {
116
+ for (const item of untracked) {
117
+ const answer = (await rl.question(`Publish untracked ${item.path} as a new ${scope === "org" ? "org-global" : "project"} ${item.type.toLowerCase()} "${item.slug}"? [y/N] `))
118
+ .trim()
119
+ .toLowerCase();
120
+ if (answer !== "y" && answer !== "yes")
121
+ continue;
122
+ const content = withFrontmatter(readFileSync(join(ctx.root, item.path), "utf8"), item.slug);
123
+ try {
124
+ const res = await ctx.client.pushCreate({
125
+ space: ctx.config.space,
126
+ type: item.type,
127
+ scope,
128
+ slug: item.slug,
129
+ content,
130
+ });
131
+ state.files[item.path] = {
132
+ kind: "asset",
133
+ id: res.id,
134
+ remoteHash: res.hash,
135
+ fileHash: sha256(readFileSync(join(ctx.root, item.path), "utf8")),
136
+ version: res.version,
137
+ };
138
+ console.log(`created ${item.path} → ${item.slug} (v1)`);
139
+ }
140
+ catch (err) {
141
+ failures++;
142
+ if (err instanceof ApiError && err.status === 409) {
143
+ console.error(`conflict ${item.path} — slug "${item.slug}" already exists. Run \`knowa pull\` first.`);
144
+ }
145
+ else {
146
+ console.error(`error ${item.path} — ${err.message}`);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ finally {
152
+ rl.close();
153
+ }
154
+ }
155
+ writeState(ctx.root, state);
156
+ return failures > 0 ? 1 : 0;
157
+ }
@@ -0,0 +1,68 @@
1
+ import { desiredFiles } from "../adapters/index.js";
2
+ import { loadContext } from "../context.js";
3
+ import { diskSnapshot } from "../fsops.js";
4
+ import { planSync } from "../sync.js";
5
+ import { readState } from "../state.js";
6
+ /**
7
+ * What would `pull` and `push` do? Local view first (works offline in
8
+ * spirit; the manifest fetch adds the upstream view). `--porcelain` prints
9
+ * one `<state>\t<path>` line per finding for CI.
10
+ */
11
+ export async function runStatus(porcelain = false) {
12
+ const ctx = loadContext();
13
+ const state = readState(ctx.root);
14
+ const disk = diskSnapshot(ctx.root);
15
+ const lines = [];
16
+ for (const [path, entry] of Object.entries(state.files)) {
17
+ const diskHash = disk.hashOf(path);
18
+ if (diskHash === null)
19
+ lines.push(["missing", path]);
20
+ else if (diskHash !== entry.fileHash)
21
+ lines.push(["modified", path]);
22
+ }
23
+ const result = await ctx.client.manifest(ctx.config.space);
24
+ if (result) {
25
+ const plan = planSync(desiredFiles(result.manifest, ctx.config.targets), state.files, disk);
26
+ for (const action of plan.actions) {
27
+ switch (action.op) {
28
+ case "create":
29
+ lines.push(["new-upstream", action.file.path]);
30
+ break;
31
+ case "update":
32
+ lines.push(["outdated", action.file.path]);
33
+ break;
34
+ case "delete":
35
+ lines.push(["deleted-upstream", action.path]);
36
+ break;
37
+ case "conflict-modified":
38
+ case "conflict-untracked":
39
+ lines.push(["conflict", action.path]);
40
+ break;
41
+ case "orphan":
42
+ lines.push(["orphaned", action.path]);
43
+ break;
44
+ }
45
+ }
46
+ }
47
+ if (porcelain) {
48
+ for (const [kind, path] of lines)
49
+ console.log(`${kind}\t${path}`);
50
+ }
51
+ else if (lines.length === 0) {
52
+ console.log("Up to date — local files match the last sync and nothing changed upstream.");
53
+ }
54
+ else {
55
+ const label = {
56
+ missing: "missing locally (pull restores)",
57
+ modified: "modified locally (push publishes, pull skips)",
58
+ "new-upstream": "new upstream (pull creates)",
59
+ outdated: "changed upstream (pull updates)",
60
+ "deleted-upstream": "deleted upstream (pull removes)",
61
+ conflict: "conflict (see `knowa pull` output)",
62
+ orphaned: "deleted upstream but modified locally",
63
+ };
64
+ for (const [kind, path] of lines)
65
+ console.log(`${kind.padEnd(17)} ${path} — ${label[kind]}`);
66
+ }
67
+ return lines.some(([kind]) => kind === "conflict") ? 2 : 0;
68
+ }
@@ -0,0 +1,39 @@
1
+ import { loadContext } from "../context.js";
2
+ import { readState } from "../state.js";
3
+ import { runPull } from "./pull.js";
4
+ const DEFAULT_INTERVAL_S = 30;
5
+ const MAX_BACKOFF_S = 300;
6
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7
+ /**
8
+ * Poll the manifest and pull on change. A quiet poll is one conditional GET
9
+ * answered 304 — no body, no writes. Never pushes. Network errors back off
10
+ * exponentially and recover on their own.
11
+ */
12
+ export async function runWatch(intervalArg) {
13
+ const interval = Math.max(5, Number(intervalArg) || DEFAULT_INTERVAL_S);
14
+ const ctx = loadContext();
15
+ console.log(`Watching "${ctx.config.space}" on ${ctx.config.url} every ~${interval}s. Ctrl-C to stop.`);
16
+ // First sync immediately so a fresh clone materializes without waiting.
17
+ let backoff = interval;
18
+ for (;;) {
19
+ try {
20
+ const etag = readState(ctx.root).etag;
21
+ const changed = etag
22
+ ? (await ctx.client.manifest(ctx.config.space, etag)) !== null
23
+ : true;
24
+ if (changed) {
25
+ const { conflicts } = await runPull(ctx);
26
+ if (conflicts.length > 0) {
27
+ console.log("(conflicted paths stay untouched until resolved or --force pulled)");
28
+ }
29
+ }
30
+ backoff = interval;
31
+ }
32
+ catch (err) {
33
+ console.error(`watch: ${err.message} — retrying in ${backoff}s`);
34
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_S);
35
+ }
36
+ // ±20% jitter so a team's watchers don't thundering-herd the manifest.
37
+ await sleep(backoff * 1000 * (0.8 + Math.random() * 0.4));
38
+ }
39
+ }
package/dist/config.js ADDED
@@ -0,0 +1,73 @@
1
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ /**
5
+ * Two config layers:
6
+ *
7
+ * - `knowa.json` at the repo root — the team's repo↔space link, COMMITTED.
8
+ * Never holds credentials.
9
+ * - The API key — `KNOWA_API_KEY` env var, falling back to
10
+ * `~/.config/knowa/credentials.json` (`{ "<url>": "knowa_sk_…" }`, 0600),
11
+ * written by `knowa init`. Per-user, never in the repo.
12
+ */
13
+ export const CONFIG_FILENAME = "knowa.json";
14
+ export const DEFAULT_URL = "https://app.knowalabs.com";
15
+ export const ALL_TARGETS = ["claude", "cursor", "docs"];
16
+ /** Walk up from cwd to the nearest directory containing knowa.json. */
17
+ export function findRepoRoot(from = process.cwd()) {
18
+ let dir = from;
19
+ for (;;) {
20
+ if (existsSync(join(dir, CONFIG_FILENAME)))
21
+ return dir;
22
+ const parent = dirname(dir);
23
+ if (parent === dir)
24
+ return null;
25
+ dir = parent;
26
+ }
27
+ }
28
+ export function readRepoConfig(root) {
29
+ const raw = JSON.parse(readFileSync(join(root, CONFIG_FILENAME), "utf8"));
30
+ if (typeof raw.space !== "string" || raw.space === "") {
31
+ throw new Error(`${CONFIG_FILENAME} is missing "space"`);
32
+ }
33
+ const targets = Array.isArray(raw.targets)
34
+ ? raw.targets.filter((t) => ALL_TARGETS.includes(t))
35
+ : [...ALL_TARGETS];
36
+ return {
37
+ url: typeof raw.url === "string" && raw.url !== "" ? raw.url.replace(/\/+$/, "") : DEFAULT_URL,
38
+ space: raw.space,
39
+ targets,
40
+ };
41
+ }
42
+ export function writeRepoConfig(root, config) {
43
+ writeFileSync(join(root, CONFIG_FILENAME), JSON.stringify(config, null, 2) + "\n");
44
+ }
45
+ function credentialsPath() {
46
+ return join(homedir(), ".config", "knowa", "credentials.json");
47
+ }
48
+ export function resolveApiKey(url) {
49
+ const fromEnv = process.env.KNOWA_API_KEY;
50
+ if (fromEnv)
51
+ return fromEnv;
52
+ try {
53
+ const creds = JSON.parse(readFileSync(credentialsPath(), "utf8"));
54
+ return creds[url] ?? null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ export function storeApiKey(url, key) {
61
+ const path = credentialsPath();
62
+ let creds = {};
63
+ try {
64
+ creds = JSON.parse(readFileSync(path, "utf8"));
65
+ }
66
+ catch {
67
+ // First key — start fresh.
68
+ }
69
+ creds[url] = key;
70
+ mkdirSync(dirname(path), { recursive: true });
71
+ writeFileSync(path, JSON.stringify(creds, null, 2) + "\n");
72
+ chmodSync(path, 0o600);
73
+ }
@@ -0,0 +1,24 @@
1
+ import { KnowaClient } from "./api.js";
2
+ import { findRepoRoot, readRepoConfig, resolveApiKey } from "./config.js";
3
+ /** Resolve repo root + config + credentials, with actionable errors. */
4
+ export function loadContext() {
5
+ const root = findRepoRoot();
6
+ if (!root) {
7
+ throw new Error("No knowa.json found in this directory or any parent. Run `knowa init` first.");
8
+ }
9
+ const config = readRepoConfig(root);
10
+ const apiKey = resolveApiKey(config.url);
11
+ if (!apiKey) {
12
+ throw new Error(`No API key for ${config.url}. Set KNOWA_API_KEY or run \`knowa init\` to store one.`);
13
+ }
14
+ return { root, config, client: new KnowaClient(config.url, apiKey) };
15
+ }
16
+ /** Strip the frontmatter block from a canonical asset file, leaving the body. */
17
+ export function bodyOf(content) {
18
+ if (!content.startsWith("---\n"))
19
+ return content;
20
+ const closing = content.indexOf("\n---\n", 4);
21
+ if (closing === -1)
22
+ return content;
23
+ return content.slice(closing + 5).replace(/^\n+/, "");
24
+ }
package/dist/fsops.js ADDED
@@ -0,0 +1,51 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { sha256 } from "./hash.js";
4
+ /** Hash-on-demand view of the working tree, rooted at the repo. */
5
+ export function diskSnapshot(root) {
6
+ return {
7
+ hashOf(path) {
8
+ try {
9
+ return sha256(readFileSync(join(root, path)));
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ },
15
+ };
16
+ }
17
+ export function readFileOrNull(root, path) {
18
+ try {
19
+ return readFileSync(join(root, path), "utf8");
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Atomic write: temp file in the same directory, then rename over. */
26
+ export function writeFileAtomic(root, path, content) {
27
+ const abs = join(root, path);
28
+ mkdirSync(dirname(abs), { recursive: true });
29
+ const tmp = `${abs}.knowa-tmp`;
30
+ writeFileSync(tmp, content);
31
+ renameSync(tmp, abs);
32
+ }
33
+ /** Delete a managed file, then prune now-empty parent dirs up to the root. */
34
+ export function deleteFileAndPrune(root, path) {
35
+ const abs = join(root, path);
36
+ if (existsSync(abs))
37
+ unlinkSync(abs);
38
+ let dir = dirname(abs);
39
+ const stop = join(root, ".");
40
+ while (dir !== stop && dir.startsWith(root)) {
41
+ try {
42
+ if (readdirSync(dir).length > 0)
43
+ break;
44
+ rmdirSync(dir);
45
+ }
46
+ catch {
47
+ break;
48
+ }
49
+ dir = dirname(dir);
50
+ }
51
+ }
package/dist/hash.js ADDED
@@ -0,0 +1,9 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * SHA-256 hex of exact bytes — must match the server's contentHashOf
4
+ * (src/lib/context/frontmatter.ts) so a file written verbatim from the API
5
+ * hashes to the manifest's value.
6
+ */
7
+ export function sha256(content) {
8
+ return createHash("sha256").update(content).digest("hex");
9
+ }
package/dist/index.js ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ import { loadContext } from "./context.js";
3
+ import { runInit } from "./commands/init.js";
4
+ import { runPull } from "./commands/pull.js";
5
+ import { runPush } from "./commands/push.js";
6
+ import { runStatus } from "./commands/status.js";
7
+ import { runWatch } from "./commands/watch.js";
8
+ const VERSION = "0.1.0";
9
+ const HELP = `knowa ${VERSION} — sync your team's AI context into this repo
10
+
11
+ Usage:
12
+ knowa init Link this repo to a Knowa space
13
+ knowa pull [--force] [--dry-run]
14
+ Materialize .claude/ .cursor/ .knowa/ from Knowa
15
+ knowa watch [--interval N] Keep them in sync (polls; pulls on change)
16
+ knowa status [--porcelain] What pull/push would do
17
+ knowa push [path…] [--org] Publish local asset edits back to Knowa
18
+
19
+ The repo link lives in knowa.json (commit it). Your API key comes from
20
+ KNOWA_API_KEY or ~/.config/knowa/credentials.json (never the repo).`;
21
+ function flag(args, name) {
22
+ const i = args.indexOf(name);
23
+ if (i === -1)
24
+ return false;
25
+ args.splice(i, 1);
26
+ return true;
27
+ }
28
+ function option(args, name) {
29
+ const i = args.indexOf(name);
30
+ if (i === -1 || i + 1 >= args.length)
31
+ return undefined;
32
+ const [, value] = args.splice(i, 2);
33
+ return value;
34
+ }
35
+ async function main() {
36
+ const args = process.argv.slice(2);
37
+ const command = args.shift();
38
+ switch (command) {
39
+ case "init":
40
+ await runInit();
41
+ return 0;
42
+ case "pull": {
43
+ const force = flag(args, "--force");
44
+ const dryRun = flag(args, "--dry-run");
45
+ const { conflicts } = await runPull(loadContext(), { force, dryRun });
46
+ return conflicts.length > 0 ? 2 : 0;
47
+ }
48
+ case "watch":
49
+ return runWatch(option(args, "--interval"));
50
+ case "status":
51
+ return runStatus(flag(args, "--porcelain"));
52
+ case "push":
53
+ return runPush(args.filter((a) => !a.startsWith("--")), { org: flag(args, "--org") });
54
+ case "--version":
55
+ case "-v":
56
+ console.log(VERSION);
57
+ return 0;
58
+ default:
59
+ console.log(HELP);
60
+ return command === undefined || command === "help" || command === "--help" ? 0 : 1;
61
+ }
62
+ }
63
+ main().then((code) => process.exit(code), (err) => {
64
+ console.error(`knowa: ${err.message}`);
65
+ process.exit(1);
66
+ });
package/dist/state.js ADDED
@@ -0,0 +1,20 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ const STATE_PATH = join(".knowa", "state.json");
4
+ export function readState(root) {
5
+ const path = join(root, STATE_PATH);
6
+ if (!existsSync(path))
7
+ return { files: {} };
8
+ try {
9
+ const raw = JSON.parse(readFileSync(path, "utf8"));
10
+ return { files: raw.files ?? {}, claudeMd: raw.claudeMd, etag: raw.etag };
11
+ }
12
+ catch {
13
+ return { files: {} };
14
+ }
15
+ }
16
+ export function writeState(root, state) {
17
+ const path = join(root, STATE_PATH);
18
+ mkdirSync(dirname(path), { recursive: true });
19
+ writeFileSync(path, JSON.stringify(state, null, 2) + "\n");
20
+ }
package/dist/sync.js ADDED
@@ -0,0 +1,61 @@
1
+ export function planSync(desired, stateFiles, disk) {
2
+ const actions = [];
3
+ const unchanged = [];
4
+ // Ownership and diffing are strictly per-path (one asset can map to
5
+ // several paths — a COMMAND syncs to both .claude/ and .cursor/).
6
+ // Tombstones and renames need no special casing: their paths simply stop
7
+ // being desired, and the leftover sweep below retires them.
8
+ const live = desired.filter((f) => !f.deleted);
9
+ const handled = new Set();
10
+ for (const file of live) {
11
+ handled.add(file.path);
12
+ const tracked = stateFiles[file.path];
13
+ const diskHash = disk.hashOf(file.path);
14
+ if (!tracked) {
15
+ if (diskHash === null) {
16
+ actions.push({ op: "create", file });
17
+ }
18
+ else {
19
+ actions.push({ op: "conflict-untracked", path: file.path, file });
20
+ }
21
+ continue;
22
+ }
23
+ if (diskHash === null) {
24
+ // The user deleted a managed file that's still desired: restore it.
25
+ actions.push({ op: "create", file });
26
+ continue;
27
+ }
28
+ // A different asset now claims a path we own (e.g. doc renames swapped
29
+ // paths): the remote effectively changed even if hashes coincide.
30
+ const sameEntity = tracked.kind === file.kind && tracked.id === file.id;
31
+ const remoteChanged = !sameEntity || tracked.remoteHash !== file.remoteHash;
32
+ const locallyModified = diskHash !== tracked.fileHash;
33
+ if (!remoteChanged) {
34
+ unchanged.push(file.path);
35
+ }
36
+ else if (!locallyModified) {
37
+ actions.push({ op: "update", file });
38
+ }
39
+ else {
40
+ actions.push({ op: "conflict-modified", path: file.path, file });
41
+ }
42
+ }
43
+ // Leftover sweep: every tracked path that's no longer desired (tombstoned,
44
+ // renamed away, aged-out, or turned invisible) is retired — deleted when
45
+ // the local copy is untouched, orphaned (kept, untracked) when modified.
46
+ for (const [path, entry] of Object.entries(stateFiles)) {
47
+ if (handled.has(path))
48
+ continue;
49
+ const diskHash = disk.hashOf(path);
50
+ if (diskHash === null) {
51
+ // Nothing on disk — silently drop the state entry.
52
+ }
53
+ else if (diskHash === entry.fileHash) {
54
+ actions.push({ op: "delete", path });
55
+ }
56
+ else {
57
+ actions.push({ op: "orphan", path });
58
+ }
59
+ }
60
+ return { actions, unchanged };
61
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@knowalabs/knowa",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Sync your team's AI context — agents, commands, skills, rules, docs — from Knowa into .claude/, .cursor/ and .knowa/ folders.",
8
+ "license": "MIT",
9
+ "type": "module",
10
+ "bin": {
11
+ "knowa": "dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "test": "vitest run",
22
+ "prepublishOnly": "npm run build && npm test"
23
+ },
24
+ "keywords": [
25
+ "ai",
26
+ "context",
27
+ "mcp",
28
+ "claude",
29
+ "cursor",
30
+ "agents",
31
+ "sync"
32
+ ],
33
+ "homepage": "https://knowalabs.com",
34
+ "devDependencies": {
35
+ "typescript": "^5.6.0",
36
+ "vitest": "^4.1.0"
37
+ }
38
+ }