sharepoint-knowledge 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """sharepoint-knowledge — SharePoint connector on the shared engine."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,118 @@
1
+ """Entra auth for sharepoint-knowledge — device-code flow + silent refresh.
2
+
3
+ The MSAL token cache is stored as a credential blob through the shared
4
+ keyring/file machinery (same posture as the Confluence PAT). The access
5
+ token itself never enters chat, logs, or command-line arguments.
6
+
7
+ SP_KNOWLEDGE_TOKEN env var short-circuits everything (tests/CI).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+ from confluence_knowledge.auth import get_credential, store_credential
17
+ from confluence_knowledge.settings import SetupError
18
+
19
+ SCOPES = ["https://graph.microsoft.com/Sites.Read.All", "offline_access"]
20
+ ENV_TOKEN = "SP_KNOWLEDGE_TOKEN"
21
+ AUTH_FILE = ".sp_auth.json"
22
+ INDEX_DIR = ".sp_knowledge_index"
23
+ SERVICE = "sharepoint-knowledge"
24
+
25
+
26
+ def _cache_ref(profile) -> str:
27
+ return f"cache:{profile.credential_ref}"
28
+
29
+
30
+ def _load_msal():
31
+ try:
32
+ import msal
33
+ except ImportError as e:
34
+ raise SetupError(
35
+ "msal isn't installed — reinstall with `pip install "
36
+ "sharepoint-knowledge[models]`") from e
37
+ return msal
38
+
39
+
40
+ def _load_cache(workdir: Path, profile):
41
+ msal = _load_msal()
42
+ blob = get_credential(workdir, _cache_ref(profile), env=ENV_TOKEN,
43
+ service=SERVICE, auth_file=AUTH_FILE,
44
+ home_dir=".sharepoint-knowledge")
45
+ if blob:
46
+ try:
47
+ cache = msal.SerializableTokenCache()
48
+ cache.deserialize(json.loads(blob))
49
+ return cache
50
+ except Exception: # noqa: BLE001 — corrupt cache → re-sign-in
51
+ return None
52
+ return None
53
+
54
+
55
+ def _save_cache(workdir: Path, home_dir: Path, profile, cache) -> str:
56
+ return store_credential(workdir, home_dir, _cache_ref(profile),
57
+ json.dumps(cache.serialize()),
58
+ service=SERVICE, auth_file=AUTH_FILE,
59
+ index_dir=INDEX_DIR)
60
+
61
+
62
+ def _app(profile, cache=None):
63
+ msal = _load_msal()
64
+ kwargs = {"token_cache": cache} if cache else {}
65
+ return msal.PublicClientApplication(
66
+ profile.client_id,
67
+ authority=f"{_get_auth_base()}/{profile.tenant_id}",
68
+ **kwargs)
69
+
70
+
71
+ def _get_auth_base() -> str:
72
+ """Authority base override for e2e (fake token endpoint)."""
73
+ return os.environ.get("SP_KNOWLEDGE_AUTH_BASE",
74
+ "https://login.microsoftonline.com")
75
+
76
+
77
+ def acquire_token(profile, workdir: Path, home_dir: Path) -> str:
78
+ """Silent first (cached refresh token); device flow only on expiry —
79
+ so normal use needs no re-sign-in after setup."""
80
+ if os.environ.get(ENV_TOKEN):
81
+ return os.environ[ENV_TOKEN]
82
+ cache = _load_cache(workdir, profile)
83
+ app = _app(profile, cache)
84
+ accounts = app.get_accounts()
85
+ if accounts:
86
+ r = app.acquire_token_silent(SCOPES, account=accounts[0])
87
+ if r and "access_token" in r:
88
+ if cache and cache.has_state_changed:
89
+ _save_cache(workdir, home_dir, profile, cache)
90
+ return r["access_token"]
91
+ raise SetupError(
92
+ "SharePoint sign-in expired — run `sharepoint-knowledge setup` to "
93
+ "re-authenticate (device code, one browser step)")
94
+
95
+
96
+ def device_sign_in(profile, workdir: Path, home_dir: Path) -> dict:
97
+ """Interactive device-code sign-in — used by `setup`. Prints the
98
+ message Microsoft returns (URL + code); blocks until approved or it
99
+ times out. Returns the stored-cache location description."""
100
+ msal = _load_msal()
101
+ cache = msal.SerializableTokenCache()
102
+ app = _app(profile, cache)
103
+ flow = app.initiate_device_flow(scopes=SCOPES)
104
+ if "user_code" not in flow:
105
+ raise SetupError(
106
+ "couldn't start device sign-in: "
107
+ f"{flow.get('error_description', flow)}")
108
+ print("\n" + flow["message"] + "\n") # "go to microsoft.com/devicelogin…"
109
+ r = app.acquire_token_by_device_flow(flow)
110
+ if "access_token" not in r:
111
+ raise SetupError(
112
+ "sign-in failed: "
113
+ f"{r.get('error_description', r.get('error', r))}")
114
+ where = _save_cache(workdir, home_dir, profile, cache)
115
+ account = r.get("id_token_claims", {})
116
+ return {"where": where,
117
+ "user": (account.get("preferred_username")
118
+ or account.get("email") or "signed in")}
@@ -0,0 +1,191 @@
1
+ """sharepoint-knowledge CLI — setup (Entra device sign-in), doctor,
2
+ prepare-models, register, update, serve, clean.
3
+
4
+ Same shape as the other knowledge CLIs. `setup` prints the exact Entra
5
+ app-registration steps for IT, collects tenant/client/site, then runs the
6
+ one-time device-code sign-in.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from confluence_knowledge.registration import register as _register
18
+ from confluence_knowledge.retrieval import prepare_models
19
+ from confluence_knowledge.settings import SetupError, ensure_gitignore
20
+
21
+ from .settings_sp import (
22
+ APP_NAME,
23
+ AUTH_ENV,
24
+ AUTH_FILE_NAME,
25
+ INDEX_DIR_NAME,
26
+ PROFILE_NAME,
27
+ SPProfile,
28
+ home,
29
+ workdir,
30
+ )
31
+
32
+ TOKEN_STEPS = """\
33
+ Entra app registration — once per tenant, done by IT (or you, if allowed):
34
+ 1. Entra portal (entra.microsoft.com) → Applications → App registrations
35
+ → New registration
36
+ 2. Name: e.g. "sharepoint-knowledge-agent" — Accounts in this org only
37
+ 3. Authentication → Enable "Allow public client flows" → YES (required
38
+ for device-code sign-in — no client secret is used or needed)
39
+ 4. API permissions → Add → Microsoft Graph → Delegated → Sites.Read.All
40
+ (+ offline_access is implied by device flow)
41
+ 5. Grant admin consent for the tenant (the delegated scope normally
42
+ requires it) — one click on the Permissions page
43
+ 6. Copy: Directory (tenant) ID and Application (client) ID — needed below
44
+
45
+ This app requests only delegated read access — it can read exactly what
46
+ the signed-in user can already read, nothing more.
47
+ """
48
+
49
+
50
+ def _setup() -> int:
51
+ print("SharePoint Knowledge — first-run setup\n")
52
+ print(TOKEN_STEPS)
53
+
54
+ tenant = input("Tenant ID (GUID): ").strip()
55
+ client = input("Client ID (application ID, GUID): ").strip()
56
+ site = input("SharePoint site URL (e.g. https://contoso.sharepoint.com"
57
+ "/sites/team): ").strip().rstrip("/")
58
+ if not (tenant and client and site):
59
+ print("tenant ID, client ID, and site URL are all required",
60
+ file=sys.stderr)
61
+ return 1
62
+
63
+ profile = SPProfile(tenant_id=tenant, client_id=client,
64
+ site_url=site, credential_ref="sp-token")
65
+ profile.save()
66
+ print(f"\nprofile saved to {home() / PROFILE_NAME}\n")
67
+
68
+ print("Sign-in (device code — one browser step). The code you get "
69
+ "never leaves the OS credential store.", flush=True)
70
+ if os.environ.get(AUTH_ENV):
71
+ print("SP_KNOWLEDGE_TOKEN is set — using it; skipping sign-in")
72
+ else:
73
+ from .auth_sp import device_sign_in
74
+ try:
75
+ r = device_sign_in(profile, workdir(), home())
76
+ print(f"signed in as {r['user']} — token cache stored at "
77
+ f"{r['where']}")
78
+ except SetupError as e:
79
+ print(f"sign-in failed: {e}", file=sys.stderr)
80
+ return 1
81
+
82
+ # make sure the workdir's gitignore hides the index + auth file
83
+ workdir().mkdir(parents=True, exist_ok=True)
84
+ ensure_gitignore(workdir(), [AUTH_FILE_NAME,
85
+ f"{INDEX_DIR_NAME}/"])
86
+
87
+ if input("\nDownload the two local models now (~175 MB, one-time)? "
88
+ "[Y/n] ").strip().lower() not in ("n", "no"):
89
+ for line in prepare_models(home()):
90
+ print(line)
91
+
92
+ print("\nsetup complete — run `sharepoint-knowledge doctor` to verify")
93
+ return 0
94
+
95
+
96
+ def _doctor() -> int:
97
+ from confluence_knowledge.update import check
98
+
99
+ from .server_sp import status
100
+ s = status()
101
+ print(json.dumps(s, indent=2))
102
+ upd = check("sharepoint-knowledge")
103
+ if upd["installed"]:
104
+ line = f"version: {upd['installed']}"
105
+ if upd["latest"]:
106
+ line += f" (latest: {upd['latest']})"
107
+ elif upd["latest"] is None:
108
+ line += " (latest: unchecked — offline or internal mirror)"
109
+ print(line)
110
+ if upd["update_available"]:
111
+ print("update available: run `sharepoint-knowledge update` "
112
+ "(or pip install -U sharepoint-knowledge[models])")
113
+ return 0
114
+
115
+
116
+ def main() -> None:
117
+ p = argparse.ArgumentParser(prog="sharepoint-knowledge",
118
+ description="Local SharePoint indexer + "
119
+ "grounded-Q&A MCP service")
120
+ sub = p.add_subparsers(dest="cmd", required=True)
121
+ sub.add_parser("setup", help="configure Entra sign-in + site")
122
+ sub.add_parser("doctor", help="report configuration + model readiness")
123
+ sub.add_parser("prepare-models", help="download the pinned local models")
124
+ sub.add_parser("update", help="upgrade this package via pip -U")
125
+ reg = sub.add_parser("register",
126
+ help="write the IDE MCP config automatically")
127
+ reg.add_argument("--ide", choices=["auto", "all", "jetbrains", "vscode"],
128
+ default="auto")
129
+ reg.add_argument("--workspace", default=None,
130
+ help="repo dir for the workspace .vscode/mcp.json")
131
+ reg.add_argument("--remove", action="store_true",
132
+ help="remove our server entry instead of adding it")
133
+ sub.add_parser("serve", help="run the stdio MCP server")
134
+ sub.add_parser("clean", help="delete all indexed data")
135
+
136
+ args = p.parse_args()
137
+ if args.cmd == "setup":
138
+ sys.exit(_setup())
139
+ if args.cmd == "doctor":
140
+ sys.exit(_doctor())
141
+ if args.cmd == "prepare-models":
142
+ for line in prepare_models(home()):
143
+ print(line)
144
+ return
145
+ if args.cmd == "update":
146
+ import subprocess
147
+ cmd = [sys.executable, "-m", "pip", "install", "-U",
148
+ "sharepoint-knowledge[models]"]
149
+ if os.name == "nt":
150
+ print("on Windows, upgrade via the venv python directly:\n "
151
+ + " ".join(f'"{c}"' if " " in c else c for c in cmd))
152
+ return
153
+ r = subprocess.run(cmd, check=False)
154
+ if r.returncode == 0:
155
+ print("\nupdated — models are pinned and unchanged; "
156
+ "run `sharepoint-knowledge doctor` to confirm")
157
+ sys.exit(r.returncode)
158
+ if args.cmd == "register":
159
+ results = _register(
160
+ ide=args.ide,
161
+ workspace=Path(args.workspace) if args.workspace else None,
162
+ remove=args.remove,
163
+ server_id=APP_NAME,
164
+ module="sharepoint_knowledge.server_sp",
165
+ env_var="SP_KNOWLEDGE_WORKDIR")
166
+ for path, action in results:
167
+ print(f"{action}: {path}")
168
+ if args.remove:
169
+ print("removed — reload the IDE to drop the server")
170
+ else:
171
+ print("registered — reload the IDE so the tools appear")
172
+ return
173
+ if args.cmd == "serve":
174
+ from .server_sp import main as serve
175
+ serve()
176
+ return
177
+ if args.cmd == "clean":
178
+ import shutil
179
+
180
+ from .settings_sp import index_root
181
+ d = index_root()
182
+ if d.exists():
183
+ shutil.rmtree(d)
184
+ print(f"deleted {d}")
185
+ else:
186
+ print("nothing to clean")
187
+ return
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
@@ -0,0 +1,69 @@
1
+ """GET-only Microsoft Graph client for sharepoint-knowledge.
2
+
3
+ Reuses the allowlist + GET-only machinery of the Confluence client: every
4
+ request passes through the same `_validate_path` — the only Graph routes
5
+ allowed are the read ones the crawler needs. File downloads use the
6
+ pre-authenticated `@microsoft.graph.downloadUrl` fetched WITHOUT the Bearer
7
+ header — the token never leaves graph.microsoft.com.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from urllib.parse import urlparse
14
+
15
+ import httpx
16
+ from confluence_knowledge.client import (
17
+ AccessError,
18
+ ConfluenceClient,
19
+ EndpointRule,
20
+ SecurityException,
21
+ )
22
+
23
+ GRAPH_RULES = [
24
+ EndpointRule(re.compile(r"sites/[^/]+:.*"), "site by hostname:path"),
25
+ EndpointRule(re.compile(r"sites/[^/]+$"), "site info"),
26
+ EndpointRule(re.compile(r"sites/[^/]+/lists/?$"), "site lists"),
27
+ EndpointRule(re.compile(r"sites/[^/]+/lists/[^/]+/items/?$"), "list items"),
28
+ EndpointRule(re.compile(r"sites/[^/]+/lists/[^/]+/items/[^/]+/?$"), "list item"),
29
+ EndpointRule(re.compile(r"sites/[^/]+/pages/?$"), "site pages"),
30
+ EndpointRule(re.compile(r"sites/[^/]+/pages/[^/]+/?$"), "site page"),
31
+ EndpointRule(re.compile(r"sites/[^/]+/drives/?$"), "document libraries"),
32
+ EndpointRule(re.compile(r"sites/[^/]+/drive/?$"), "default drive"),
33
+ EndpointRule(re.compile(r"sites/[^/]+/drive/root:.+"), "item by path"),
34
+ EndpointRule(re.compile(r"drives/[^/]+/root/children/?$"), "library root"),
35
+ EndpointRule(re.compile(r"drives/[^/]+/items/[^/]+/children/?$"), "folder children"),
36
+ EndpointRule(re.compile(r"drives/[^/]+/items/[^/]+/?$"), "item metadata"),
37
+ EndpointRule(re.compile(r"me/?$"), "identity"),
38
+ ]
39
+
40
+
41
+ def make_client(base_url: str, token: str) -> ConfluenceClient:
42
+ """Graph client — Bearer stays on the API host only."""
43
+ return ConfluenceClient(base_url, {"Authorization": f"Bearer {token}"},
44
+ GRAPH_RULES)
45
+
46
+
47
+ def download_file(url: str, max_bytes: int = 25 * 1024 * 1024) -> bytes:
48
+ """Fetch a pre-authenticated downloadUrl — NO Authorization header.
49
+
50
+ The URL embeds a short-lived download token; sending our Bearer would
51
+ leak it to a different host (and is unnecessary — these links are
52
+ self-authenticating). https only (localhost allowed for tests).
53
+ """
54
+ u = urlparse(url)
55
+ if u.scheme != "https" and u.hostname not in ("localhost", "127.0.0.1"):
56
+ raise SecurityException(
57
+ "download URL isn't https — refusing to fetch")
58
+ with httpx.Client(timeout=60.0, follow_redirects=True) as http, \
59
+ http.stream("GET", url) as r:
60
+ # bounded stream — don't buffer an unbounded response
61
+ if r.status_code >= 400:
62
+ raise AccessError(f"download failed: HTTP {r.status_code}")
63
+ out = bytearray()
64
+ for chunk in r.iter_bytes(1 << 20):
65
+ out += chunk
66
+ if len(out) > max_bytes:
67
+ raise AccessError(
68
+ f"file exceeds {max_bytes} bytes — refusing to buffer")
69
+ return bytes(out)
@@ -0,0 +1,277 @@
1
+ """Durable BFS over a SharePoint site (pages + document libraries).
2
+
3
+ Queue entries: site:<id> → list:<list_id> + drive:<drive_id>;
4
+ list:<id> → page:<item_id>…; drive:<id> → item:<drive_id>:<item_id>…
5
+ File bytes are routed through the shared extract_files (docx/pdf/pptx/xlsx/
6
+ csv/txt/md/html). Site-page canvas HTML goes through extract_chunks.
7
+ Only items reachable through the chosen root are ever fetched.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import uuid
15
+ from urllib.parse import urlparse
16
+
17
+ from confluence_knowledge.client import AccessError, ConfluenceClient
18
+ from confluence_knowledge.extract import extract_chunks
19
+ from confluence_knowledge.extract_files import extract_file_chunks, is_secret_name
20
+ from confluence_knowledge.index import Index
21
+ from confluence_knowledge.settings import SetupError
22
+
23
+ MAX_FILE_BYTES = 25 * 1024 * 1024
24
+ MAX_FILES = 20_000
25
+
26
+
27
+ def resolve_root(client: ConfluenceClient, url: str) -> tuple[str, str]:
28
+ """SharePoint URL → (root queue entry, site webUrl for citations).
29
+
30
+ Accepts:
31
+ https://host.sharepoint.com/sites/name → whole site (pages+libraries)
32
+ …/sites/name/<library or folder path> → that subtree only
33
+ https://host.sharepoint.com → root site
34
+ """
35
+ u = urlparse(url.strip())
36
+ if u.scheme != "https" and u.hostname not in ("localhost", "127.0.0.1"):
37
+ raise SetupError("SharePoint URL must be https")
38
+ host = u.hostname
39
+ path = u.path.rstrip("/") or "/"
40
+
41
+ # site path = /sites/{name} or /teams/{name} (or root "/")
42
+ site_path, item_path = "/", ""
43
+ parts = [p for p in path.split("/") if p]
44
+ if parts and parts[0] in ("sites", "teams"):
45
+ site_path = "/" + "/".join(parts[:2])
46
+ item_path = "/".join(parts[2:])
47
+ site_path = site_path.rstrip("/")
48
+
49
+ if site_path == "/":
50
+ site = client.get(f"sites/{host}").json()
51
+ else:
52
+ site = client.get(f"sites/{host}:{site_path}").json()
53
+ site_id = site["id"]
54
+ site_web = site.get("webUrl", url)
55
+
56
+ if item_path:
57
+ item = client.get(
58
+ f"sites/{site_id}/drive/root:/{item_path}").json()
59
+ drive = (item.get("parentReference") or {}).get("driveId") or \
60
+ client.get(f"sites/{site_id}/drive").json()["id"]
61
+ return f"item:{drive}:{item['id']}", site_web
62
+ return f"site:{site_id}", site_web
63
+
64
+
65
+ def _paged(client: ConfluenceClient, path: str, params: dict | None = None):
66
+ """Follow @odata.nextLink — absolute links are validated inside the
67
+ client's origin+base by _validate_path, so pagination stays in scope."""
68
+ while True:
69
+ r = client.get(path, params)
70
+ d = r.json()
71
+ yield from d.get("value", [])
72
+ nxt = d.get("@odata.nextLink")
73
+ if not nxt:
74
+ return
75
+ path, params = nxt, None
76
+
77
+
78
+ class SPCrawler:
79
+ def __init__(self, client: ConfluenceClient, index: Index, embedder=None,
80
+ downloader=None):
81
+ self.client = client
82
+ self.index = index
83
+ self.embedder = embedder
84
+ self.downloader = downloader # injected for tests; real one in server
85
+
86
+ # ---- expansions ----
87
+
88
+ def _expand_site(self, site_id: str) -> tuple[list[str], list[str]]:
89
+ """site → the Site Pages list + every document library."""
90
+ out, warns = [], []
91
+ for lst in _paged(self.client, f"sites/{site_id}/lists"):
92
+ name = lst.get("displayName", "")
93
+ base = (lst.get("list") or {}).get("template", "")
94
+ if name == "Site Pages" or base == "webPageLibrary":
95
+ out.append(f"list:{site_id}:{lst['id']}")
96
+ for drv in _paged(self.client, f"sites/{site_id}/drives"):
97
+ out.append(f"drive:{drv['id']}")
98
+ return out, warns
99
+
100
+ def _expand_drive(self, drive_id: str) -> list[str]:
101
+ return [f"item:{drive_id}:{it['id']}"
102
+ for it in _paged(self.client, f"drives/{drive_id}/root/children")]
103
+
104
+ def _expand_item(self, drive_id: str, item_id: str) -> list[str]:
105
+ return [f"item:{drive_id}:{it['id']}"
106
+ for it in _paged(self.client,
107
+ f"drives/{drive_id}/items/{item_id}/children")]
108
+
109
+ # ---- pages ----
110
+
111
+ def _page_from_list_item(self, site_id: str, list_id: str,
112
+ item_id: str) -> tuple[dict, list, list]:
113
+ d = self.client.get(
114
+ f"sites/{site_id}/lists/{list_id}/items/{item_id}",
115
+ {"$expand": "fields"}).json()
116
+ fields = d.get("fields", {})
117
+ title = fields.get("Title") or d.get("name") or item_id
118
+ canvas = fields.get("CanvasContent1") or fields.get("WikiField") or ""
119
+ warns: list[str] = []
120
+ chunks = []
121
+ if canvas:
122
+ html = canvas
123
+ try:
124
+ parts = json.loads(canvas)
125
+ if isinstance(parts, list):
126
+ html = "\n".join(p.get("innerHtml", "")
127
+ for p in parts if isinstance(p, dict))
128
+ except (json.JSONDecodeError, TypeError):
129
+ pass # raw HTML already
130
+ chunks, warns = extract_chunks(html)
131
+ else:
132
+ warns.append("no canvas content — page may be empty or classic")
133
+ page = {"page_id": f"page:{item_id}", "title": title,
134
+ "url": (d.get("webUrl")
135
+ or (fields.get("_dlc_DocIdUrl") or {}).get("Url", "")),
136
+ "version": str(d.get("lastModifiedDateTime") or ""),
137
+ "content_hash": hashlib.sha256(canvas.encode()).hexdigest(),
138
+ "warnings": warns}
139
+ return page, chunks, warns
140
+
141
+ def _page_from_item(self, drive_id: str, item_id: str,
142
+ downloader) -> tuple[dict, list, list]:
143
+ d = self.client.get(
144
+ f"drives/{drive_id}/items/{item_id}",
145
+ {"$select": "id,name,size,webUrl,lastModifiedDateTime,"
146
+ "@microsoft.graph.downloadUrl"}).json()
147
+ name = d.get("name", item_id)
148
+ warns: list[str] = []
149
+ if is_secret_name(name):
150
+ # disclose-and-skip — same posture as local files: the file is
151
+ # never a page in the index, only a warning on the job
152
+ return None, [], [f"{name}: looks like a secret — skipped"]
153
+ dl_url = d.get("@microsoft.graph.downloadUrl")
154
+ if not dl_url or (d.get("size") or 0) > MAX_FILE_BYTES:
155
+ why = ("no download URL" if not dl_url
156
+ else f"too large ({d['size']} bytes)")
157
+ return None, [], [f"{name}: {why} — skipped"]
158
+ data = downloader(dl_url)
159
+ chunks, warns = extract_file_chunks(name, data)
160
+ return {"page_id": f"file:{item_id}", "title": name,
161
+ "url": d.get("webUrl", ""),
162
+ "version": str(d.get("lastModifiedDateTime", "")),
163
+ "content_hash": hashlib.sha256(data).hexdigest(),
164
+ "warnings": warns}, chunks, warns
165
+
166
+ # ---- the crawl ----
167
+
168
+ def start(self, root_entry: str) -> str:
169
+ job_id = uuid.uuid4().hex[:12]
170
+ self.index.abandon_running_jobs()
171
+ self.index.create_job(job_id, root_entry)
172
+ self.index.save_job(job_id, "running", [root_entry], [])
173
+ return job_id
174
+
175
+ def _checkpoint(self, job_id, queue, errors, seen, levels, warns) -> None:
176
+ with self.index.lock, self.index.db:
177
+ self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
178
+ (f"seen:{job_id}", json.dumps(sorted(seen))))
179
+ self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
180
+ (f"levels:{job_id}", json.dumps(levels)))
181
+ self.index.db.execute("INSERT OR REPLACE INTO meta VALUES (?,?)",
182
+ (f"warns:{job_id}", json.dumps(warns)))
183
+ self.index.db.execute(
184
+ "UPDATE jobs SET queue=?, errors=? WHERE job_id=?",
185
+ (json.dumps(queue), json.dumps(errors), job_id))
186
+
187
+ def step(self, job_id: str) -> dict:
188
+ job = self.index.job(job_id)
189
+ if job is None:
190
+ raise AccessError(f"unknown job {job_id}")
191
+ if job["state"] != "running":
192
+ return {"job_id": job_id, "state": job["state"],
193
+ "remaining": len(job["queue"]), "errors": job["errors"]}
194
+
195
+ queue: list[str] = job["queue"]
196
+ errors: list[str] = job["errors"]
197
+ seen_raw = self.index.meta_get(f"seen:{job_id}")
198
+ seen: set[str] = set(json.loads(seen_raw)) if seen_raw else set()
199
+ levels_raw = self.index.meta_get(f"levels:{job_id}")
200
+ levels: dict[str, int] = (json.loads(levels_raw) if levels_raw else {})
201
+ warns_raw = self.index.meta_get(f"warns:{job_id}")
202
+ warns: list[str] = json.loads(warns_raw) if warns_raw else []
203
+
204
+ processed = 0
205
+ while queue:
206
+ if len(seen) >= MAX_FILES:
207
+ errors.append(f"file cap reached ({MAX_FILES}) — remaining skipped")
208
+ queue.clear()
209
+ break
210
+ if processed:
211
+ self._checkpoint(job_id, queue, errors, seen, levels, warns)
212
+ entry = queue.pop(0)
213
+ processed += 1
214
+ try:
215
+ if entry.startswith("site:"):
216
+ kids, w = self._expand_site(entry[5:])
217
+ warns.extend(w)
218
+ queue.extend(k for k in kids if k not in seen)
219
+ continue
220
+ if entry.startswith("list:"):
221
+ _, site_id, list_id = entry.split(":", 2)
222
+ for it in _paged(
223
+ self.client,
224
+ f"sites/{site_id}/lists/{list_id}/items"):
225
+ queue.append(f"page:{site_id}:{list_id}:{it['id']}")
226
+ continue
227
+ if entry.startswith("drive:"):
228
+ queue.extend(self._expand_drive(entry[6:]))
229
+ continue
230
+ if entry.startswith("page:"):
231
+ _, site_id, list_id, item_id = entry.split(":", 3)
232
+ page, chunks, w = self._page_from_list_item(
233
+ site_id, list_id, item_id)
234
+ warns.extend(w)
235
+ elif entry.startswith("item:"):
236
+ _, drive_id, item_id = entry.split(":", 2)
237
+ meta = self.client.get(
238
+ f"drives/{drive_id}/items/{item_id}").json()
239
+ if meta.get("folder") is not None:
240
+ queue.extend(self._expand_item(drive_id, item_id))
241
+ continue
242
+ page, chunks, w = self._page_from_item(
243
+ drive_id, item_id, self.downloader)
244
+ warns.extend(w)
245
+ if page is None:
246
+ continue
247
+ else:
248
+ errors.append(f"unknown queue entry {entry}")
249
+ continue
250
+ except AccessError as e:
251
+ errors.append(f"{entry}: {e}")
252
+ continue
253
+ pid = page["page_id"]
254
+ if pid in seen:
255
+ continue
256
+ for c in chunks:
257
+ c.chunk_id = f"{pid}:{page['content_hash'][:8]}:{c.ordinal}"
258
+ vecs = None
259
+ if self.embedder is not None and chunks:
260
+ vecs = self.embedder.embed(
261
+ [f"{c.heading_path}\n{c.text}" for c in chunks])
262
+ self.index.upsert_page({
263
+ "page_id": pid, "parent_id": None, "title": page["title"],
264
+ "url": page["url"], "level": levels.get(pid, 0),
265
+ "version": page["version"],
266
+ "content_hash": page["content_hash"],
267
+ "warnings": page["warnings"]}, chunks, vecs)
268
+ seen.add(pid)
269
+
270
+ self._checkpoint(job_id, queue, errors, seen, levels, warns)
271
+ self.index.publish(seen)
272
+ self.index.save_job(job_id, "done" if not queue else "running",
273
+ queue, errors)
274
+ return {"job_id": job_id,
275
+ "state": "done" if not queue else "running",
276
+ "remaining": len(queue), "pages_seen": len(seen),
277
+ "errors": errors, "warnings": warns}
@@ -0,0 +1,334 @@
1
+ """stdio MCP server for sharepoint-knowledge — the only surface the agent
2
+ reaches. Scope = configured site + signed-in account + chosen subtree."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import threading
9
+
10
+ from confluence_knowledge.client import AccessError, SecurityException
11
+ from confluence_knowledge.index import Index
12
+ from confluence_knowledge.retrieval import (
13
+ LocalEmbedder,
14
+ LocalReranker,
15
+ check_citations,
16
+ retrieve,
17
+ )
18
+ from confluence_knowledge.settings import SetupError
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ from .auth_sp import acquire_token
22
+ from .client_sp import download_file, make_client
23
+ from .crawl_sp import SPCrawler, resolve_root
24
+ from .settings_sp import APP_NAME, SPProfile, graph_base, home, index_root
25
+
26
+ mcp = FastMCP("sharepoint-knowledge")
27
+
28
+ _scopes: dict[str, dict] = {}
29
+ _evidence_sets: dict[str, list[dict]] = {}
30
+ _indexes: dict[str, Index] = {}
31
+ _clients: dict[str, object] = {}
32
+ _EMBEDDER = None
33
+ _RERANKER = None
34
+ _model_lock = threading.Lock()
35
+ _models_ready = threading.Event()
36
+ _warm_lock = threading.Lock()
37
+ _warm_started = False
38
+
39
+ LOADING_NOTE = "loading local models — call this tool again"
40
+
41
+
42
+ def _index_for(scope_key: str) -> Index:
43
+ idx = _indexes.get(scope_key)
44
+ if idx is None:
45
+ idx = _indexes[scope_key] = Index(index_root() / f"{scope_key}.db")
46
+ return idx
47
+
48
+
49
+ def _embedder():
50
+ global _EMBEDDER
51
+ with _model_lock:
52
+ if _EMBEDDER is None:
53
+ _EMBEDDER = LocalEmbedder(home(), APP_NAME)
54
+ return _EMBEDDER
55
+
56
+
57
+ def _reranker():
58
+ global _RERANKER
59
+ with _model_lock:
60
+ if _RERANKER is None:
61
+ _RERANKER = LocalReranker(home(), APP_NAME)
62
+ return _RERANKER
63
+
64
+
65
+ def _warm_models() -> None:
66
+ try:
67
+ _embedder()
68
+ _reranker()
69
+ except SetupError:
70
+ pass
71
+ finally:
72
+ _models_ready.set()
73
+
74
+
75
+ def _start_warmup() -> None:
76
+ global _warm_started
77
+ with _warm_lock:
78
+ if _warm_started:
79
+ return
80
+ _warm_started = True
81
+ threading.Thread(target=_warm_models, name="model-warmup", daemon=True).start()
82
+
83
+
84
+ def _models_loaded(wait: float) -> bool:
85
+ _start_warmup()
86
+ return _models_ready.wait(wait)
87
+
88
+
89
+ def _client_for(account: str) -> object:
90
+ cli = _clients.get(account)
91
+ if cli is None:
92
+ profile = SPProfile.load()
93
+ token = acquire_token(profile, workdir_from_home(), home())
94
+ cli = _clients[account] = make_client(graph_base(), token)
95
+ return cli
96
+
97
+
98
+ def workdir_from_home():
99
+ from .settings_sp import workdir
100
+ return workdir()
101
+
102
+
103
+ def _scope(scope_handle: str) -> dict:
104
+ s = _scopes.get(scope_handle)
105
+ if s is None:
106
+ parts = scope_handle.split(":", 3)
107
+ if len(parts) == 4 and parts[0] == "scope" and \
108
+ connect(parts[3])["scope_handle"] == scope_handle:
109
+ return _scopes[scope_handle]
110
+ raise SetupError("unknown scope — call connect first")
111
+ return s
112
+
113
+
114
+ @mcp.tool()
115
+ def status() -> dict:
116
+ """Report setup readiness — profile present, token reachable, models."""
117
+ models = home() / "models"
118
+ try:
119
+ profile = SPProfile.load()
120
+ try:
121
+ token = acquire_token(profile, workdir_from_home(), home())
122
+ who = "token ready"
123
+ except SetupError as e:
124
+ who = f"sign-in needed: {e}"
125
+ cfg = {"site": profile.site_url, "tenant": profile.tenant_id,
126
+ "auth": who}
127
+ configured = who == "token ready"
128
+ except SetupError as e:
129
+ cfg = {"error": str(e)}
130
+ configured = False
131
+ token = None
132
+ if token:
133
+ try:
134
+ cli = _client_for(profile.site_url)
135
+ me = cli.get("me").json()
136
+ cfg["signed_in_as"] = me.get("userPrincipalName") or me.get("mail")
137
+ except AccessError as e:
138
+ cfg["auth"] = str(e)
139
+ configured = False
140
+ return {
141
+ "configured": configured,
142
+ "profile": cfg,
143
+ "embedding_model_ready": (models / "all-MiniLM-L6-v2").exists(),
144
+ "reranker_model_ready": (models / "ms-marco-MiniLM-L6-v2").exists(),
145
+ "models_loaded": _models_ready.is_set() and _EMBEDDER is not None,
146
+ "index_root": str(index_root()),
147
+ "live_scopes": sorted(_scopes),
148
+ }
149
+
150
+
151
+ @mcp.tool()
152
+ def connect(root: str) -> dict:
153
+ """Bind a scope to a SharePoint URL — a site (pages + all libraries),
154
+ or a library/folder path inside it. One root per connect."""
155
+ profile = SPProfile.load()
156
+ cli = _client_for(profile.site_url)
157
+ if root.startswith(("site:", "item:", "drive:", "list:", "page:")):
158
+ root_entry, site_web = root, profile.site_url
159
+ else:
160
+ root_entry, site_web = resolve_root(cli, root)
161
+ principal = "me"
162
+ scope_key = profile.scope_key(principal, root_entry)
163
+ idx = _index_for(scope_key)
164
+ st = idx.stats()
165
+ handle = f"scope:{scope_key}:{root}"
166
+ _scopes[handle] = {"key": scope_key, "root_entry": root_entry,
167
+ "site_web": site_web, "client": cli}
168
+ _start_warmup()
169
+ return {
170
+ "scope_handle": handle,
171
+ "root": root_entry,
172
+ "site": site_web,
173
+ "cached": st["pages"] > 0,
174
+ "cached_pages": st["pages"],
175
+ "cached_as_of": st["as_of"],
176
+ }
177
+
178
+
179
+ _workers: dict[str, threading.Thread] = {}
180
+ _worker_errors: dict[str, str] = {}
181
+ _workers_lock = threading.Lock()
182
+
183
+
184
+ def _worker_loop(s: dict, job_id: str) -> None:
185
+ idx = _index_for(s["key"])
186
+ try:
187
+ _models_ready.wait()
188
+ c = SPCrawler(s["client"], idx, embedder=_embedder(),
189
+ downloader=download_file)
190
+ r = c.step(job_id)
191
+ while r["state"] == "running":
192
+ r = c.step(job_id)
193
+ except Exception as e: # noqa: BLE001 — surfaced via job state
194
+ err = f"{type(e).__name__}: {e}"
195
+ _worker_errors[f"{s['key']}:{job_id}"] = err
196
+ job = idx.job(job_id)
197
+ if job and job["state"] == "running":
198
+ idx.save_job(job_id, "error", job["queue"], job["errors"] + [err])
199
+
200
+
201
+ def _ensure_worker(s: dict, job_id: str) -> None:
202
+ key = f"{s['key']}:{job_id}"
203
+ with _workers_lock:
204
+ t = _workers.get(key)
205
+ if t is not None and t.is_alive():
206
+ return
207
+ _workers[key] = threading.Thread(
208
+ target=_worker_loop, args=(s, job_id),
209
+ name=f"crawl-{job_id}", daemon=True)
210
+ _workers[key].start()
211
+
212
+
213
+ @mcp.tool()
214
+ def start_crawl(scope_handle: str, refresh: bool = False) -> dict:
215
+ """Begin indexing the root in the background (Site Pages + libraries)."""
216
+ s = _scope(scope_handle)
217
+ idx = _index_for(s["key"])
218
+ c = SPCrawler(s["client"], idx)
219
+ job_id = c.start(s["root_entry"])
220
+ _ensure_worker(s, job_id)
221
+ return {"job_id": job_id, "state": "running",
222
+ "note": "crawl runs in the background — call crawl_step to poll"}
223
+
224
+
225
+ @mcp.tool()
226
+ def crawl_step(scope_handle: str, job_id: str) -> dict:
227
+ """Read crawl progress instantly; resumes a dead worker automatically."""
228
+ s = _scope(scope_handle)
229
+ idx = _index_for(s["key"])
230
+ job = idx.job(job_id)
231
+ if job is None:
232
+ raise AccessError(f"unknown job {job_id}")
233
+
234
+ def progress() -> dict:
235
+ p = idx.job_progress(job_id)
236
+ warns = idx.meta_get(f"warns:{job_id}")
237
+ if warns:
238
+ p["warnings"] = json.loads(warns)
239
+ return p
240
+
241
+ if job["state"] == "running":
242
+ if not _models_ready.is_set():
243
+ _start_warmup()
244
+ note = LOADING_NOTE
245
+ else:
246
+ note = None
247
+ _ensure_worker(s, job_id)
248
+ p = progress()
249
+ if note:
250
+ p["note"] = note
251
+ return p
252
+ p = progress()
253
+ if p["state"] == "done":
254
+ p["note"] = "no further calls needed — the index is published"
255
+ return p
256
+
257
+
258
+ def _coverage(idx: Index) -> str:
259
+ job = idx.latest_job()
260
+ if job is None or job["state"] == "abandoned":
261
+ return "unknown"
262
+ if job["state"] == "running":
263
+ return "refresh in progress — answering from the previous snapshot"
264
+ return "partial" if job["errors"] else "complete"
265
+
266
+
267
+ @mcp.tool()
268
+ def search(scope_handle: str, question: str) -> dict:
269
+ """Hybrid retrieve + local rerank; returns bounded, cited evidence."""
270
+ s = _scope(scope_handle)
271
+ idx = _index_for(s["key"])
272
+ st = idx.stats()
273
+ if st["pages"] == 0:
274
+ job = idx.latest_job()
275
+ if job and job["state"] == "running":
276
+ return {"evidence": [], "coverage": "crawling",
277
+ "note": f"crawl {job['job_id']} still running — keep calling crawl_step "
278
+ "until state is done, then search"}
279
+ return {"evidence": [], "coverage": "empty",
280
+ "note": "nothing indexed — run a crawl first"}
281
+ if not _models_loaded(10.0):
282
+ return {"evidence": [], "coverage": "loading", "note": LOADING_NOTE}
283
+ ev = retrieve(idx, question, _embedder(), _reranker())
284
+ set_id = f"ev:{len(_evidence_sets)}:{s['key'][:8]}"
285
+ _evidence_sets[set_id] = ev
286
+ return {
287
+ "evidence_set": set_id,
288
+ "coverage": _coverage(idx),
289
+ "as_of": st["as_of"],
290
+ "evidence": [
291
+ {"chunk_id": e["chunk_id"], "page_title": e["page_title"],
292
+ "page_url": e["page_url"], "heading": e["heading"],
293
+ "score": e.get("rerank_score"), "text": e["text"][:2000]}
294
+ for e in ev
295
+ ],
296
+ }
297
+
298
+
299
+ @mcp.tool()
300
+ def get_evidence(scope_handle: str, chunk_ids: list[str]) -> dict:
301
+ """Return full text of previously returned chunks — same scope only."""
302
+ s = _scope(scope_handle)
303
+ idx = _index_for(s["key"])
304
+ all_chunks = {c["chunk_id"]: c for c in idx.active_chunks()}
305
+ out = []
306
+ for cid in chunk_ids[:16]:
307
+ c = all_chunks.get(cid)
308
+ if c is None:
309
+ raise SecurityException(f"chunk {cid} not in this scope's index")
310
+ p = idx.active_pages().get(c["page_id"], {})
311
+ out.append({"chunk_id": cid, "text": c["text"],
312
+ "page_title": p.get("title", ""), "page_url": p.get("url", "")})
313
+ return {"chunks": out}
314
+
315
+
316
+ @mcp.tool()
317
+ def verify_citations(scope_handle: str, evidence_set: str,
318
+ mappings: list[dict]) -> dict:
319
+ _scope(scope_handle)
320
+ ev = _evidence_sets.get(evidence_set)
321
+ if ev is None or not evidence_set.endswith(_scopes[scope_handle]["key"][:8]):
322
+ raise SecurityException(
323
+ "unknown or cross-scope evidence set — run search again and cite its evidence_set")
324
+ return check_citations(ev, mappings)
325
+
326
+
327
+ def main() -> None:
328
+ logging.getLogger("httpx").setLevel(logging.WARNING)
329
+ _start_warmup()
330
+ mcp.run()
331
+
332
+
333
+ if __name__ == "__main__":
334
+ main()
@@ -0,0 +1,80 @@
1
+ """Filesystem layout + connection profile for sharepoint-knowledge.
2
+
3
+ Same machinery as the other connectors: ~/.sharepoint-knowledge home,
4
+ SP_KNOWLEDGE_* env overrides, a non-secret profile.json. The token cache
5
+ itself is a credential blob in the OS keyring (or protected-file fallback).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from confluence_knowledge.settings import (
17
+ SetupError,
18
+ default_home,
19
+ resolve_workdir,
20
+ )
21
+
22
+ APP_NAME = "sharepoint-knowledge"
23
+ INDEX_DIR_NAME = ".sp_knowledge_index"
24
+ PROFILE_NAME = "profile.json"
25
+ AUTH_FILE_NAME = ".sp_auth.json"
26
+ AUTH_ENV = "SP_KNOWLEDGE_TOKEN"
27
+
28
+
29
+ def home() -> Path:
30
+ return default_home("SP_KNOWLEDGE_HOME", ".sharepoint-knowledge")
31
+
32
+
33
+ def workdir() -> Path:
34
+ return resolve_workdir("SP_KNOWLEDGE_WORKDIR",
35
+ "SP_KNOWLEDGE_HOME", ".sharepoint-knowledge")
36
+
37
+
38
+ def index_root() -> Path:
39
+ return workdir() / INDEX_DIR_NAME
40
+
41
+
42
+ def graph_base() -> str:
43
+ """Microsoft Graph base — overridable for the fake server in e2e tests."""
44
+ return os.environ.get("SP_KNOWLEDGE_GRAPH_BASE",
45
+ "https://graph.microsoft.com/v1.0")
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class SPProfile:
50
+ """Non-secret connection profile. The token cache is a credential blob."""
51
+
52
+ tenant_id: str
53
+ client_id: str
54
+ site_url: str # canonical https site URL the user indexed
55
+ credential_ref: str # opaque key — not the token
56
+
57
+ def scope_key(self, principal: str, root: str) -> str:
58
+ raw = f"{self.site_url}|{principal}|{root}"
59
+ return hashlib.sha256(raw.encode()).hexdigest()[:24]
60
+
61
+ def save(self) -> Path:
62
+ p = home() / PROFILE_NAME
63
+ p.parent.mkdir(parents=True, exist_ok=True)
64
+ tmp = p.with_suffix(".tmp")
65
+ tmp.write_text(json.dumps({
66
+ "tenant_id": self.tenant_id, "client_id": self.client_id,
67
+ "site_url": self.site_url, "credential_ref": self.credential_ref,
68
+ }, indent=2))
69
+ os.replace(tmp, p)
70
+ return p
71
+
72
+ @staticmethod
73
+ def load() -> SPProfile:
74
+ p = home() / PROFILE_NAME
75
+ if not p.exists():
76
+ raise SetupError("no SharePoint profile configured — run "
77
+ "`sharepoint-knowledge setup`")
78
+ d = json.loads(p.read_text())
79
+ return SPProfile(d["tenant_id"], d["client_id"], d["site_url"],
80
+ d["credential_ref"])
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: sharepoint-knowledge
3
+ Version: 0.1.0
4
+ Summary: SharePoint connector for the grounded-Q&A engine — site pages + document libraries, Entra device-code sign-in, local models, cited answers
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/nkunal1992/agents
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: confluence-knowledge[files]>=0.4.1
10
+ Requires-Dist: msal>=1.31
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: mcp>=1.8
13
+ Provides-Extra: models
14
+ Requires-Dist: confluence-knowledge[models]>=0.4.1; extra == "models"
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0; extra == "dev"
17
+ Requires-Dist: ruff>=0.5; extra == "dev"
18
+ Requires-Dist: python-docx>=1.1; extra == "dev"
19
+ Requires-Dist: python-pptx>=1.0; extra == "dev"
20
+ Requires-Dist: openpyxl>=3.1; extra == "dev"
21
+ Requires-Dist: pypdf>=5.0; extra == "dev"
22
+
23
+ # sharepoint-knowledge
24
+
25
+ SharePoint connector on the shared grounded-Q&A engine — Site Pages +
26
+ document libraries (docx/pdf/pptx/xlsx/csv/txt/md/html), Entra device-code
27
+ sign-in, local models, cited answers. Same engine as
28
+ [`confluence-knowledge`](../confluence-knowledge) and
29
+ [`local-knowledge`](../local-knowledge).
30
+
31
+ - `sharepoint_knowledge/auth_sp.py` — MSAL device-code flow, token cache in
32
+ the OS keyring (protected-file fallback), silent refresh
33
+ - `sharepoint_knowledge/client_sp.py` — GET-only Graph client; downloads go
34
+ through `@microsoft.graph.downloadUrl` WITHOUT the Bearer header
35
+ - `sharepoint_knowledge/crawl_sp.py` — durable BFS site → pages + libraries
36
+ - `sharepoint_knowledge/server_sp.py` — the `sharepoint-knowledge/*` tools
37
+ - `sharepoint_knowledge/cli_sp.py` — setup/doctor/prepare-models/register/
38
+ update/serve/clean
39
+
40
+ See `docs/sharepoint-knowledge.md` for the full guide.
@@ -0,0 +1,12 @@
1
+ sharepoint_knowledge/__init__.py,sha256=J8KcyQSaE9FuCWplCAnhlSJW0DNI48H4oDqCM8gxiUo,97
2
+ sharepoint_knowledge/auth_sp.py,sha256=vzschQ7NEL8XYb2LnjMkEFKh7Pu5h0OSQVyqQSJ7_Fk,4314
3
+ sharepoint_knowledge/cli_sp.py,sha256=n7iFP-MKg2QQp6NtJGL9ponUw-9ll2fIYzpokZrSG0k,7153
4
+ sharepoint_knowledge/client_sp.py,sha256=ZvRWSuz0NATPgS8JH_WTO-ef17kCXNu8odH3JSsacOU,3055
5
+ sharepoint_knowledge/crawl_sp.py,sha256=7t90HE_cCl5pLIRKYhrLo1Wqg3h-SUIoLEO4fCT39aY,12242
6
+ sharepoint_knowledge/server_sp.py,sha256=VueieIIAiVXmpZ0N0j_IvY-XI5oq6P8jrYhfz_ppI_g,10601
7
+ sharepoint_knowledge/settings_sp.py,sha256=Coz78N3VpmulKyvHUpTdiAdzd1NODx581qsMfXDDxjQ,2483
8
+ sharepoint_knowledge-0.1.0.dist-info/METADATA,sha256=xZsG0XWvi8pGVj_yCZ5tdy4imqvfjusbAqorfZ9RP0E,1779
9
+ sharepoint_knowledge-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ sharepoint_knowledge-0.1.0.dist-info/entry_points.txt,sha256=Hwjazt5G5FCCo1vQLJMl6WcHA6Ss-Ux4kIob2bLlj_I,74
11
+ sharepoint_knowledge-0.1.0.dist-info/top_level.txt,sha256=LKQ769avV5sRwxHMNtEe5bTs2HmV79KZ3to22ymUBX8,21
12
+ sharepoint_knowledge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sharepoint-knowledge = sharepoint_knowledge.cli_sp:main
@@ -0,0 +1 @@
1
+ sharepoint_knowledge