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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 68 additions & 10 deletions src/oikb/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,29 @@ def _make_client(url: str | None, token: str | None):
)


def _resolve_connector(source: str, branch: str | None = None, path: str | None = None):
"""Resolve a source string to the appropriate connector."""
def _resolve_connector(
source: str,
branch: str | None = None,
path: str | None = None,
auth: dict[str, Any] | None = None,
):
"""Resolve a source string to the appropriate connector.

`auth` carries source-specific credentials — token, client_id,
client_secret, tenant_id, base_url, whatever a given connector's
constructor accepts — sourced from an .oikb.yaml `auth:` block or
the CLI's --auth flag. This function stays agnostic about what any
one connector needs: it just forwards the dict as **auth and lets
the connector's own __init__ validate it (and fall back to its env
vars when a key is missing, same as today). An unsupported key
surfaces as a normal TypeError naming the bad field.
"""
auth = auth or {}

if source.startswith("github:"):
from oikb.connectors.github import GitHubConnector, parse_github_source
parsed = parse_github_source(source)
return GitHubConnector(owner=parsed["owner"], repo=parsed["repo"], branch=branch, path=path or parsed.get("path"))
return GitHubConnector(owner=parsed["owner"], repo=parsed["repo"], branch=branch, path=path or parsed.get("path"), **auth)

if source.startswith("gitlab:"):
from oikb.connectors.gitlab import GitLabConnector, parse_gitlab_source
Expand All @@ -59,6 +76,7 @@ def _resolve_connector(source: str, branch: str | None = None, path: str | None
branch=branch,
path=path or parsed.get("path"),
is_wiki=bool(parsed.get("wiki")),
**auth,
)

if source.startswith("s3://"):
Expand Down Expand Up @@ -116,7 +134,7 @@ def _resolve_connector(source: str, branch: str | None = None, path: str | None
if source.startswith("sharepoint:"):
from oikb.connectors.sharepoint import SharePointConnector, parse_sharepoint_source
parsed = parse_sharepoint_source(source)
return SharePointConnector(site=parsed["site"], library=parsed.get("library", "Documents"))
return SharePointConnector(site=parsed["site"], site_path=parsed["site_path"], library=parsed.get("library", "Documents"), **auth)

if source.startswith("nextcloud:"):
from oikb.connectors.nextcloud import NextcloudConnector, parse_nextcloud_source
Expand All @@ -131,7 +149,7 @@ def _resolve_connector(source: str, branch: str | None = None, path: str | None
if source.startswith("bitbucket:"):
from oikb.connectors.bitbucket import BitbucketConnector, parse_bitbucket_source
parsed = parse_bitbucket_source(source)
return BitbucketConnector(owner=parsed["owner"], repo=parsed["repo"], branch=branch, path=path or parsed.get("path"))
return BitbucketConnector(owner=parsed["owner"], repo=parsed["repo"], branch=branch, path=path or parsed.get("path"), **auth)

if source.startswith("discord:"):
from oikb.connectors.discord import DiscordConnector, parse_discord_source
Expand Down Expand Up @@ -368,6 +386,27 @@ def _build_cli_filter(max_file_size: str | None):
return build_manifest_filter(max_size=parse_size(max_file_size))


def _parse_auth_option(pairs: tuple[str, ...]) -> dict[str, str]:
"""Turn repeated --auth key=value flags into a credentials dict.

Mirrors the shape of an .oikb.yaml `auth:` block so single-source
CLI invocations (no .oikb.yaml) can also authenticate connectors
like gitlab/github/sharepoint/bitbucket without env vars.
"""
auth: dict[str, str] = {}
for pair in pairs:
if "=" not in pair:
raise click.BadOptionUsage(
"auth", f"--auth expects key=value, got: {pair!r}"
)
key, _, value = pair.partition("=")
key = key.strip()
if not key:
raise click.BadOptionUsage("auth", f"--auth expects key=value, got: {pair!r}")
auth[key] = value
return auth


# ── sync ────────────────────────────────────────────────────────

@cli.command()
Expand All @@ -380,6 +419,14 @@ def _build_cli_filter(max_file_size: str | None):
@click.option("--name", default=None, help="Target a specific entry in .oikb.yaml by name/kb-id.")
@click.option("--concurrency", default=1, type=int, help="Parallel upload workers (default: 1, sequential).")
@click.option("--max-file-size", default=None, help="Skip files larger than this (e.g. 50mb, 1gb).")
@click.option(
"--auth",
"auth_pairs",
multiple=True,
metavar="KEY=VALUE",
help="Source credential, repeatable (e.g. --auth token=... --auth base_url=...). "
"Ignored in .oikb.yaml mode — use each entry's own auth: block instead.",
)
@click.pass_context
def sync(
ctx: click.Context,
Expand All @@ -394,6 +441,7 @@ def sync(
name: str | None,
concurrency: int,
max_file_size: str | None,
auth_pairs: tuple[str, ...],
):
"""Incremental sync from a source to a Knowledge Base.

Expand Down Expand Up @@ -427,13 +475,14 @@ def sync(
entry_branch = entry.get("branch")
entry_path = entry.get("path")
entry_filter = entry.get("filter", {})
entry_auth = entry.get("auth", {})

if not entry_source or not entry_kb:
click.echo(click.style(f"Skipping invalid entry (needs source + kb-id): {entry}", fg="yellow"), err=True)
continue

try:
connector = _resolve_connector(entry_source, entry_branch, entry_path)
connector = _resolve_connector(entry_source, entry_branch, entry_path, auth=entry_auth)
client = _make_client(url, token)

if not quiet:
Expand Down Expand Up @@ -501,7 +550,7 @@ def sync(
sys.exit(1)

try:
connector = _resolve_connector(source, branch, source_path)
connector = _resolve_connector(source, branch, source_path, auth=_parse_auth_option(auth_pairs))
except (FileNotFoundError, ImportError, ValueError) as e:
click.echo(click.style(f"Error: {e}", fg="red"), err=True)
sys.exit(1)
Expand Down Expand Up @@ -554,6 +603,13 @@ def sync(
@click.option("--branch", default=None, help="Branch for GitHub sources.")
@click.option("--path", "source_path", default=None, help="Subdirectory within the source.")
@click.option("-v", "--verbose", is_flag=True, help="Show detailed output.")
@click.option(
"--auth",
"auth_pairs",
multiple=True,
metavar="KEY=VALUE",
help="Source credential, repeatable (e.g. --auth token=... --auth base_url=...).",
)
@click.pass_context
def diff(
ctx: click.Context,
Expand All @@ -564,6 +620,7 @@ def diff(
branch: str | None,
source_path: str | None,
verbose: bool,
auth_pairs: tuple[str, ...],
):
"""Preview what a sync would do (alias for sync --dry-run)."""
if not kb:
Expand All @@ -573,7 +630,7 @@ def diff(
from oikb.sync import run_sync

try:
connector = _resolve_connector(source, branch, source_path)
connector = _resolve_connector(source, branch, source_path, auth=_parse_auth_option(auth_pairs))
except (FileNotFoundError, ImportError, ValueError) as e:
click.echo(click.style(f"Error: {e}", fg="red"), err=True)
sys.exit(1)
Expand Down Expand Up @@ -924,9 +981,10 @@ def validate(config_file: str | None, deep: bool):
has_errors = True
continue

# Syntax check: resolve the connector.
# Syntax check: resolve the connector (using its configured auth,
# since some connectors — e.g. SharePoint — authenticate eagerly).
try:
_resolve_connector(source)
_resolve_connector(source, auth=entry.get("auth", {}))
except Exception as e:
click.echo(click.style(f" ✗ {entry_name}: {e}", fg="red"))
has_errors = True
Expand Down
97 changes: 83 additions & 14 deletions src/oikb/connectors/sharepoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
encrypted PEM keys.

The two auth methods are mutually exclusive.

Select Microsoft Endpoints. Use SHAREPOINT_CLOUD set to
one of:
- SHAREPOINT_CLOUD=commercial // default
- SHAREPOINT_CLOUD=gcc_high
- SHAREPOINT_CLOUD=dod
"""

from __future__ import annotations
Expand All @@ -23,13 +29,29 @@

from oikb.connectors import BaseConnector, ManifestEntry

_CLOUD_ENDPOINTS: dict[str, dict[str, str]] = {
"commercial": {
"authority": "https://login.microsoftonline.com",
"graph": "https://graph.microsoft.com/v1.0",
},
"gcc_high": {
"authority": "https://login.microsoftonline.us",
"graph": "https://graph.microsoft.us/v1.0",
},
"dod": {
"authority": "https://login.microsoftonline.us",
"graph": "https://dod-graph.microsoft.us/v1.0",
},
}


class SharePointConnector(BaseConnector):
"""Sync files from a SharePoint document library."""

def __init__(
self,
site: str,
site_path: str = "",
library: str = "Documents",
tenant_id: str | None = None,
client_id: str | None = None,
Expand All @@ -38,6 +60,7 @@ def __init__(
certificate_password: str | None = None,
):
self.site = site
self.site_path = site_path.strip("/")
self.library = library

tid = tenant_id or os.environ.get("SHAREPOINT_TENANT_ID", "")
Expand All @@ -46,6 +69,11 @@ def __init__(
cert_path = certificate_path or os.environ.get("SHAREPOINT_CERTIFICATE_PATH", "")
cert_password = certificate_password or os.environ.get("SHAREPOINT_CERTIFICATE_PASSWORD", "")

cloud = os.environ.get("SHAREPOINT_CLOUD", "commercial")
if cloud not in _CLOUD_ENDPOINTS:
raise ValueError(f"SHAREPOINT_CLOUD must be one of {list(_CLOUD_ENDPOINTS)}, got '{cloud}'")
endpoints = _CLOUD_ENDPOINTS[cloud]

if not tid or not cid:
raise ValueError(
"SharePoint credentials required. Set env vars:\n"
Expand All @@ -66,31 +94,34 @@ def __init__(
" SHAREPOINT_CERTIFICATE_PATH (certificate)"
)

token_url = f"https://login.microsoftonline.com/{tid}/oauth2/v2.0/token"
token_url = f"{endpoints['authority']}/{tid}/oauth2/v2.0/token"

if cert_path:
access_token = _get_token_via_certificate(
token_url=token_url,
client_id=cid,
certificate_path=cert_path,
certificate_password=cert_password or None,
graph_base=endpoints["graph"]
)
else:
access_token = _get_token_via_secret(
token_url=token_url,
client_id=cid,
client_secret=secret,
graph_base=endpoints["graph"]
)

self._http = httpx.Client(
base_url="https://graph.microsoft.com/v1.0",
base_url=endpoints["graph"],
headers={"Authorization": f"Bearer {access_token}"},
timeout=60.0,
follow_redirects=True,
)

# Resolve site ID.
site_resp = self._http.get(f"/sites/{self.site}")
site_identifier = f"{self.site}:/{self.site_path}" if self.site_path else self.site
site_resp = self._http.get(f"/sites/{site_identifier}")
site_resp.raise_for_status()
self._site_id = site_resp.json()["id"]

Expand Down Expand Up @@ -144,15 +175,15 @@ def close(self) -> None:
# ── Auth helpers ────────────────────────────────────────────────


def _get_token_via_secret(token_url: str, client_id: str, client_secret: str) -> str:
def _get_token_via_secret(token_url: str, client_id: str, client_secret: str, graph_base: str) -> str:
"""Obtain an access token using client ID + client secret."""
token_resp = httpx.post(
token_url,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "https://graph.microsoft.com/.default",
"scope": f"{graph_base.rsplit('/v1.0', 1)[0]}/.default",
},
)
token_resp.raise_for_status()
Expand All @@ -163,6 +194,7 @@ def _get_token_via_certificate(
token_url: str,
client_id: str,
certificate_path: str,
graph_base: str,
certificate_password: str | None = None,
) -> str:
"""Obtain an access token using client ID + certificate (JWT assertion).
Expand Down Expand Up @@ -231,7 +263,7 @@ def _get_token_via_certificate(
"client_id": client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion,
"scope": "https://graph.microsoft.com/.default",
"scope": f"{graph_base.rsplit('/v1.0', 1)[0]}/.default",
},
)
token_resp.raise_for_status()
Expand All @@ -240,13 +272,50 @@ def _get_token_via_certificate(

# ── Source parser ───────────────────────────────────────────────

_SITE_PATH_PREFIXES = ("sites", "teams")
_SEPARATOR = "::"

def parse_sharepoint_source(source: str) -> dict[str, str | None]:
"""Parse sharepoint:site/library or sharepoint:site."""
def parse_sharepoint_source(source: str) -> dict[str, str]:
"""Parse a SharePoint source string. Supports:
sharepoint:<hostname>/<library>
sharepoint:<hostname>/sites/<site_name>/<library>
sharepoint:<hostname>/sites/<site_name>/<subsite>::<library>
"""
source = source.removeprefix("sharepoint:")
parts = source.split("/", 1)
site = parts[0]
library = parts[1] if len(parts) > 1 else "Documents"
if not site:
raise ValueError("Invalid SharePoint source. Expected: sharepoint:<site>[/library]")
return {"site": site, "library": library}
host, _, rest = source.partition("/")
if not host:
raise ValueError(
"Invalid SharePoint source. Expected one of:\n"
" sharepoint:<hostname>/<library>\n"
" sharepoint:<hostname>/sites/<site_name>/<library>\n"
" sharepoint:<hostname>/sites/<site_name>/<subsite>::<library>"
)

if _SEPARATOR in rest:
site_path_str, _, library = rest.partition(_SEPARATOR)
site_path = site_path_str.strip("/")
if not library:
raise ValueError(f"Invalid SharePoint source: '{_SEPARATOR}' must be followed by a library name.")
return {"site": host, "site_path": site_path, "library": library}

segments = [s for s in rest.split("/") if s]

if segments and segments[0] in _SITE_PATH_PREFIXES:
if len(segments) < 3:
raise ValueError(
f"Invalid SharePoint source. '{segments[0]}/...' requires a site name and "
f"library, e.g. sharepoint:{host}/{segments[0]}/TeamSite/Documents"
)
if len(segments) > 3:
raise ValueError(
"Ambiguous SharePoint source with a subsite path — separate the site path "
f"from the library explicitly with '{_SEPARATOR}', e.g.\n"
f" sharepoint:{host}/{'/'.join(segments[:-1])}{_SEPARATOR}{segments[-1]}"
)
site_path = "/".join(segments[:2]) # e.g. "sites/TeamSite"
library = segments[2]
else:
site_path = ""
library = "/".join(segments) if segments else "Documents"

return {"site": host, "site_path": site_path, "library": library}
1 change: 1 addition & 0 deletions src/oikb/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ async def _run_entry_locked(entry: dict, dry_run: bool = False) -> dict | None:
source,
branch=entry.get("branch"),
path=entry.get("path"),
auth=entry.get("auth", {}),
)
client = _make_client(
url=entry.get("url"),
Expand Down