ycb 3.4.2__tar.gz → 3.4.4__tar.gz

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.
Files changed (75) hide show
  1. {ycb-3.4.2 → ycb-3.4.4}/.env.example +1 -1
  2. {ycb-3.4.2 → ycb-3.4.4}/PKG-INFO +8 -4
  3. {ycb-3.4.2 → ycb-3.4.4}/README.md +7 -3
  4. {ycb-3.4.2 → ycb-3.4.4}/pyproject.toml +1 -1
  5. {ycb-3.4.2 → ycb-3.4.4}/src/__init__.py +1 -1
  6. {ycb-3.4.2 → ycb-3.4.4}/src/cli/main.py +138 -34
  7. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/airtable_connector.py +1 -1
  8. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/analytics_connector.py +2 -2
  9. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/asana_connector.py +1 -1
  10. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/ashby_connector.py +1 -1
  11. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/bamboohr_connector.py +1 -1
  12. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/base_connector.py +79 -0
  13. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/basecamp_connector.py +1 -1
  14. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/calendly_connector.py +1 -1
  15. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/clickup_connector.py +1 -1
  16. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/datadog_connector.py +1 -1
  17. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/deel_connector.py +1 -1
  18. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/discord_connector.py +1 -1
  19. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/dropbox_connector.py +1 -1
  20. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/facebook_connector.py +1 -1
  21. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/front_connector.py +1 -1
  22. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/github_connector.py +1 -1
  23. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/gitlab_connector.py +1 -1
  24. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/hubspot_connector.py +1 -1
  25. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/jira_connector.py +1 -1
  26. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/linear_connector.py +1 -1
  27. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/monday_connector.py +1 -1
  28. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/notion_connector.py +1 -1
  29. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/outlook_teams_connector.py +2 -2
  30. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/rippling_connector.py +1 -1
  31. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/salesforce_connector.py +1 -1
  32. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/search_connector.py +3 -3
  33. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/segment_connector.py +1 -1
  34. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/slack_connector.py +6 -2
  35. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/todoist_connector.py +1 -1
  36. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/twitter_connector.py +1 -1
  37. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/zoom_connector.py +1 -1
  38. {ycb-3.4.2 → ycb-3.4.4}/src/licensing/tier_mapping.py +113 -14
  39. {ycb-3.4.2 → ycb-3.4.4}/.gitignore +0 -0
  40. {ycb-3.4.2 → ycb-3.4.4}/src/cli/__init__.py +0 -0
  41. {ycb-3.4.2 → ycb-3.4.4}/src/cli/ui/__init__.py +0 -0
  42. {ycb-3.4.2 → ycb-3.4.4}/src/cli/ui/animation.py +0 -0
  43. {ycb-3.4.2 → ycb-3.4.4}/src/cli/ui/components.py +0 -0
  44. {ycb-3.4.2 → ycb-3.4.4}/src/cli/ui/dashboard.py +0 -0
  45. {ycb-3.4.2 → ycb-3.4.4}/src/cli/ui/logger.py +0 -0
  46. {ycb-3.4.2 → ycb-3.4.4}/src/config/__init__.py +0 -0
  47. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/__init__.py +0 -0
  48. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/gmail_connector.py +0 -0
  49. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/google_docs_connector.py +0 -0
  50. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/google_drive_connector.py +0 -0
  51. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/google_sheets_connector.py +0 -0
  52. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/registry.py +0 -0
  53. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/slack_oauth.py +0 -0
  54. {ycb-3.4.2 → ycb-3.4.4}/src/connectors/web_crawler_connector.py +0 -0
  55. {ycb-3.4.2 → ycb-3.4.4}/src/licensing/__init__.py +0 -0
  56. {ycb-3.4.2 → ycb-3.4.4}/src/licensing/validator.py +0 -0
  57. {ycb-3.4.2 → ycb-3.4.4}/src/models/__init__.py +0 -0
  58. {ycb-3.4.2 → ycb-3.4.4}/src/models/domain.py +0 -0
  59. {ycb-3.4.2 → ycb-3.4.4}/src/processors/__init__.py +0 -0
  60. {ycb-3.4.2 → ycb-3.4.4}/src/processors/chunker.py +0 -0
  61. {ycb-3.4.2 → ycb-3.4.4}/src/processors/knowledge_extractor.py +0 -0
  62. {ycb-3.4.2 → ycb-3.4.4}/src/processors/skills_synthesizer.py +0 -0
  63. {ycb-3.4.2 → ycb-3.4.4}/src/qa/__init__.py +0 -0
  64. {ycb-3.4.2 → ycb-3.4.4}/src/qa/ask_engine.py +0 -0
  65. {ycb-3.4.2 → ycb-3.4.4}/src/qa/providers/__init__.py +0 -0
  66. {ycb-3.4.2 → ycb-3.4.4}/src/qa/providers/anthropic_provider.py +0 -0
  67. {ycb-3.4.2 → ycb-3.4.4}/src/qa/providers/base.py +0 -0
  68. {ycb-3.4.2 → ycb-3.4.4}/src/qa/providers/ollama_provider.py +0 -0
  69. {ycb-3.4.2 → ycb-3.4.4}/src/qa/providers/openai_provider.py +0 -0
  70. {ycb-3.4.2 → ycb-3.4.4}/src/qa/providers/openrouter_provider.py +0 -0
  71. {ycb-3.4.2 → ycb-3.4.4}/src/storage/__init__.py +0 -0
  72. {ycb-3.4.2 → ycb-3.4.4}/src/storage/database.py +0 -0
  73. {ycb-3.4.2 → ycb-3.4.4}/src/storage/storage_manager.py +0 -0
  74. {ycb-3.4.2 → ycb-3.4.4}/src/sync/__init__.py +0 -0
  75. {ycb-3.4.2 → ycb-3.4.4}/src/sync/sync_scheduler.py +0 -0
@@ -36,7 +36,7 @@ SLACK_CLIENT_ID=your_slack_app_client_id
36
36
  SLACK_CLIENT_SECRET=your_slack_app_client_secret
37
37
 
38
38
  # User Configuration: Generated automatically per user when running 'ycb connect slack'
39
- SLACK_USER_TOKEN=xoxp-your-user-token
39
+ # SLACK_USER_TOKEN=xoxp-your-user-token
40
40
 
41
41
  # DEPRECATED (v3.1.0+): Bot Tokens (xoxb-) are no longer supported for privacy/RBAC
42
42
  # SLACK_BOT_TOKEN=xoxb-your-token
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ycb
3
- Version: 3.4.2
3
+ Version: 3.4.4
4
4
  Summary: Your Company Brain: Automated local knowledge extraction pipeline converting docs & threads into AI agent skills
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: anthropic>=0.40
@@ -19,14 +19,18 @@ Requires-Dist: slack-sdk
19
19
  Requires-Dist: sqlalchemy>=2.0
20
20
  Description-Content-Type: text/markdown
21
21
 
22
- # 🧠 Your Company Brain (v3.4.2)
22
+ # 🧠 Your Company Brain (v3.4.4)
23
23
 
24
24
  **Your Company Brain** is an automated, offline-first knowledge extraction pipeline and multi-provider AI Q&A system. It connects to **38 connectors** across your company's communication, engineering, HR, productivity, customer support, and analytics stack, pulls all scattered information, runs it through local or cloud LLMs, and outputs structured "Skills" — machine-readable procedure cards that AI agents can directly execute, or lets you query your company data directly in your terminal using natural-language search (`ycb --ask`).
25
25
 
26
26
  Instead of manually writing instruction manuals for your AI tools or searching across 10 different dashboards, Your Company Brain generates them automatically and answers questions across any model you choose.
27
27
 
28
- > 🚀 **What's New in v3.4.2 — Max Subscription Tier, Self-Healing Licensing, Dedicated Auth & Windows ASCII UI**:
29
- > - **Max Monthly Subscription Tier**: New `"max"` tier unlocks all **38 connectors** with automated 30-day subscription management.
28
+ > 🚀 **What's New in v3.4.4 — Test Tier Enterprise Access & Placeholder Credential Filtering**:
29
+ > - **Test License Key Support**: Internal/test license keys (`tier: "test"`) now correctly map to full Enterprise-level connector access (all 38 connectors unlocked) across `status`, `sync`, and `connect`.
30
+ > - **Template Placeholder Filtering**: Connectors no longer falsely report as `[ACTIVE]` when `.env.example` placeholders (e.g. `xoxp-your-user-token`, `your_...`) are present in local configuration files.
31
+ > - **Guided `ycb connect` with Credential Links**: Every credential prompt shows exactly where to get the key — direct link to the platform's dashboard (e.g. `https://github.com/settings/tokens` for GitHub, `https://linear.app/settings/api` for Linear) across all 38 connectors.
32
+ > - **Smart `ycb sync` Onboarding**: When no connectors are configured, `ycb sync` shows a clean panel with tier-specific `ycb connect` examples instead of launching an empty idle dashboard.
33
+ > - **Max Monthly Subscription Tier**: `"max"` tier unlocks all **38 connectors** with automated 30-day subscription management and background self-healing.
30
34
  > - **Self-Healing License Synchronization**: When key or email updates occur in `.env`, the CLI automatically validates and heals local cache in the background with zero manual re-login required.
31
35
  > - **Automated Occasional Expiry & Freshness Check**: Verifies license validity every 24 hours and upon subscription expiration with a 72-hour offline grace period and clean renewal notices.
32
36
  > - **Dedicated Auth Commands (`ycb login` & `ycb logout`)**: Easily switch accounts, authenticate with license key and registered email, and clear local sessions securely.
@@ -1,11 +1,15 @@
1
- # 🧠 Your Company Brain (v3.4.2)
1
+ # 🧠 Your Company Brain (v3.4.4)
2
2
 
3
3
  **Your Company Brain** is an automated, offline-first knowledge extraction pipeline and multi-provider AI Q&A system. It connects to **38 connectors** across your company's communication, engineering, HR, productivity, customer support, and analytics stack, pulls all scattered information, runs it through local or cloud LLMs, and outputs structured "Skills" — machine-readable procedure cards that AI agents can directly execute, or lets you query your company data directly in your terminal using natural-language search (`ycb --ask`).
4
4
 
5
5
  Instead of manually writing instruction manuals for your AI tools or searching across 10 different dashboards, Your Company Brain generates them automatically and answers questions across any model you choose.
6
6
 
7
- > 🚀 **What's New in v3.4.2 — Max Subscription Tier, Self-Healing Licensing, Dedicated Auth & Windows ASCII UI**:
8
- > - **Max Monthly Subscription Tier**: New `"max"` tier unlocks all **38 connectors** with automated 30-day subscription management.
7
+ > 🚀 **What's New in v3.4.4 — Test Tier Enterprise Access & Placeholder Credential Filtering**:
8
+ > - **Test License Key Support**: Internal/test license keys (`tier: "test"`) now correctly map to full Enterprise-level connector access (all 38 connectors unlocked) across `status`, `sync`, and `connect`.
9
+ > - **Template Placeholder Filtering**: Connectors no longer falsely report as `[ACTIVE]` when `.env.example` placeholders (e.g. `xoxp-your-user-token`, `your_...`) are present in local configuration files.
10
+ > - **Guided `ycb connect` with Credential Links**: Every credential prompt shows exactly where to get the key — direct link to the platform's dashboard (e.g. `https://github.com/settings/tokens` for GitHub, `https://linear.app/settings/api` for Linear) across all 38 connectors.
11
+ > - **Smart `ycb sync` Onboarding**: When no connectors are configured, `ycb sync` shows a clean panel with tier-specific `ycb connect` examples instead of launching an empty idle dashboard.
12
+ > - **Max Monthly Subscription Tier**: `"max"` tier unlocks all **38 connectors** with automated 30-day subscription management and background self-healing.
9
13
  > - **Self-Healing License Synchronization**: When key or email updates occur in `.env`, the CLI automatically validates and heals local cache in the background with zero manual re-login required.
10
14
  > - **Automated Occasional Expiry & Freshness Check**: Verifies license validity every 24 hours and upon subscription expiration with a 72-hour offline grace period and clean renewal notices.
11
15
  > - **Dedicated Auth Commands (`ycb login` & `ycb logout`)**: Easily switch accounts, authenticate with license key and registered email, and clear local sessions securely.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ycb"
7
- version = "3.4.2"
7
+ version = "3.4.4"
8
8
  description = "Your Company Brain: Automated local knowledge extraction pipeline converting docs & threads into AI agent skills"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,2 +1,2 @@
1
1
  """Company Brain package root"""
2
- __version__ = "3.4.2"
2
+ __version__ = "3.4.4"
@@ -19,6 +19,7 @@ from src.licensing.tier_mapping import (
19
19
  TIER_CONNECTORS,
20
20
  CONNECTOR_CATEGORIES,
21
21
  CONNECTOR_ENV_VARS,
22
+ CONNECTOR_CREDENTIAL_LINKS,
22
23
  get_cached_plan_tier,
23
24
  is_connector_permitted,
24
25
  save_connector_credentials,
@@ -183,8 +184,9 @@ def login(license_key, email):
183
184
  os.environ["YCB_USER_EMAIL"] = email
184
185
 
185
186
  plan_tier = res.plan_tier or "solo"
187
+ tier_title = "Test (Enterprise Access)" if plan_tier.lower() == "test" else plan_tier.title()
186
188
  console.print(f"\n[bold green][OK] Successfully logged in as [cyan]{email}[/cyan]![/bold green]")
187
- console.print(f"Plan tier: [bold cyan]{plan_tier.title()}[/bold cyan] | Seats: [bold cyan]{res.seat_limit or 1}[/bold cyan]\n")
189
+ console.print(f"Plan tier: [bold cyan]{tier_title}[/bold cyan] | Seats: [bold cyan]{res.seat_limit or 1}[/bold cyan]\n")
188
190
 
189
191
 
190
192
  @cli.command()
@@ -240,6 +242,39 @@ def sync(days, once, interactive, plain, connector):
240
242
  else:
241
243
  target_connectors = None
242
244
 
245
+ # ── Pre-flight connector check ────────────────────────────────────────────
246
+ # Resolve enabled connectors once here so we can give immediate feedback.
247
+ # We re-use the same list for the scheduler below.
248
+ enabled_connectors = get_enabled_connectors(target_connectors=target_connectors, plan_tier=plan_tier)
249
+
250
+ if not enabled_connectors:
251
+ # Suggest the first few connectors allowed on the user's tier
252
+ tier_connectors = TIER_CONNECTORS.get(plan_tier.lower(), ["google_docs", "slack"])
253
+ examples = tier_connectors[:3] if tier_connectors else ["google_docs", "slack", "github"]
254
+ example_lines = "\n".join(f" [cyan]ycb connect {c}[/cyan]" for c in examples)
255
+
256
+ console.print(
257
+ Panel.fit(
258
+ "[bold yellow]No connectors are currently connected or configured.[/bold yellow]\n\n"
259
+ "To ingest and sync your company knowledge, connect at least one platform:\n"
260
+ " [bold cyan]ycb connect <connector_name>[/bold cyan]\n\n"
261
+ f"[bold]Available in your {plan_tier.title()} plan:[/bold]\n"
262
+ f"{example_lines}\n\n"
263
+ "Run [bold cyan]ycb status[/bold cyan] to see all available connectors for your plan tier.",
264
+ title="[bold cyan]Company Brain — Sync[/bold cyan]",
265
+ border_style="yellow",
266
+ )
267
+ )
268
+ return
269
+
270
+ # Show a brief summary of what will be synced
271
+ connector_names = [c.get_source_name() for c in enabled_connectors]
272
+ console.print(
273
+ f"[bold green][OK][/bold green] Connected applications "
274
+ f"({len(connector_names)}): [bold cyan]{', '.join(connector_names)}[/bold cyan]\n"
275
+ )
276
+ # ─────────────────────────────────────────────────────────────────────────
277
+
243
278
  scheduler = SyncScheduler(sync_interval_minutes=30)
244
279
 
245
280
  # Setup UI state and logging
@@ -314,15 +349,25 @@ def connect(connector_name):
314
349
  return
315
350
 
316
351
  if name == "slack":
352
+ console.print()
353
+ console.print(
354
+ "[bold]Slack requires a Slack App with OAuth enabled.[/bold]\n"
355
+ " [dim]Where to get credentials:[/dim] "
356
+ "[cyan]https://api.slack.com/apps[/cyan]\n"
357
+ " 1. Create a new Slack App (or use an existing one)\n"
358
+ " 2. Under [bold]OAuth & Permissions[/bold], add the user token scopes you need\n"
359
+ " 3. Copy the [bold]Client ID[/bold] and [bold]Client Secret[/bold] from [bold]Basic Information[/bold]"
360
+ )
361
+ console.print()
317
362
  client_id = os.getenv("SLACK_CLIENT_ID", "")
318
363
  client_secret = os.getenv("SLACK_CLIENT_SECRET", "")
319
364
 
320
365
  if not client_id:
321
- console.print("[cyan]Admin Setup: SLACK_CLIENT_ID not found in config.[/cyan]")
366
+ console.print("[cyan]SLACK_CLIENT_ID not found in config.[/cyan]")
322
367
  client_id = click.prompt("Enter Slack App Client ID (SLACK_CLIENT_ID)").strip()
323
368
 
324
369
  if not client_secret:
325
- console.print("[cyan]Admin Setup: SLACK_CLIENT_SECRET not found in config.[/cyan]")
370
+ console.print("[cyan]SLACK_CLIENT_SECRET not found in config.[/cyan]")
326
371
  client_secret = click.prompt("Enter Slack App Client Secret (SLACK_CLIENT_SECRET)").strip()
327
372
 
328
373
  console.print("[cyan]Launching browser to authorize your personal Slack account...[/cyan]")
@@ -343,16 +388,37 @@ def connect(connector_name):
343
388
  return
344
389
 
345
390
  required_vars = CONNECTOR_ENV_VARS[name]
346
- console.print(f"[cyan]Connecting '{name}' ({len(required_vars)} credential field(s) required)...[/cyan]")
391
+ console.print(f"\n[bold cyan]Connecting '{name}'[/bold cyan] ({len(required_vars)} credential field(s) required)")
392
+ console.print()
347
393
 
348
394
  credentials = {}
349
395
  for var in required_vars:
396
+ # Print the "where to get this" hint if we have one
397
+ hint = CONNECTOR_CREDENTIAL_LINKS.get(var)
398
+ if hint:
399
+ label, url = hint
400
+ if url.startswith("http"):
401
+ console.print(f" [dim]{var}[/dim]")
402
+ console.print(f" [bold]Where to get it:[/bold] {label}")
403
+ console.print(f" [cyan]{url}[/cyan]")
404
+ else:
405
+ # It's a freeform hint (e.g. "e.g. myorg/repo1")
406
+ console.print(f" [dim]{var}[/dim]")
407
+ console.print(f" [bold]Hint:[/bold] {label}")
408
+ console.print(f" [dim]{url}[/dim]")
409
+ console.print()
410
+
350
411
  existing_val = os.getenv(var, "")
351
- val = click.prompt(f"Enter {var}", default=existing_val if existing_val else None)
412
+ val = click.prompt(
413
+ f" {var}",
414
+ default=existing_val if existing_val else None,
415
+ show_default=bool(existing_val),
416
+ )
352
417
  credentials[var] = val
418
+ console.print()
353
419
 
354
420
  save_connector_credentials(name, credentials)
355
- console.print(f"[bold green][OK] Connected connector '{name}'![/bold green]")
421
+ console.print(f"[bold green][OK] '{name}' connected successfully![/bold green]")
356
422
 
357
423
 
358
424
  @cli.command()
@@ -495,7 +561,8 @@ def status():
495
561
 
496
562
  account_label = f" | [bold cyan]Account:[/bold cyan] [bold white]{active_email}[/bold white]" if active_email else ""
497
563
  key_label = f" | [bold cyan]Key:[/bold cyan] [dim]{active_key}[/dim]" if active_key else ""
498
- console.print(f"\n[bold cyan]License Tier:[/bold cyan] [bold yellow]{plan_tier.title()}[/bold yellow]{account_label}{key_label}\n")
564
+ tier_title = "Test (Enterprise Access)" if plan_tier.lower() == "test" else plan_tier.title()
565
+ console.print(f"\n[bold cyan]License Tier:[/bold cyan] [bold yellow]{tier_title}[/bold yellow]{account_label}{key_label}\n")
499
566
 
500
567
  def print_grouped(connectors_list, item_format_fn):
501
568
  conn_set = set(connectors_list)
@@ -526,7 +593,7 @@ def status():
526
593
  if locked_connectors:
527
594
  print_grouped(locked_connectors, lambda c: f" [bold red][LOCKED][/bold red] [red]{c}[/red] [gray50](Requires Enterprise tier)[/gray50]")
528
595
  else:
529
- console.print(" [gray50](None - Max/Enterprise unlocks all connectors!)[/gray50]")
596
+ console.print(" [gray50](None - Max/Enterprise/Test unlocks all connectors!)[/gray50]")
530
597
 
531
598
  # Also display skills summary table if available
532
599
  skills = StorageManager.get_all_skills()
@@ -715,8 +782,13 @@ def _provider_key_env_var(provider: str) -> str:
715
782
  return mapping.get(provider, "")
716
783
 
717
784
 
718
- def _apply_model_switch(provider_name: str, model_id: str) -> None:
719
- """Persist provider + model to .env; prompt for API key only if missing."""
785
+ def _apply_model_switch(provider_name: str, model_id: str, force_key_update: bool = False) -> None:
786
+ """Persist provider + model to .env; prompt for API key when needed.
787
+
788
+ Prompts for an API key when:
789
+ - switching to a cloud provider that has no key saved yet, OR
790
+ - force_key_update=True (explicit --update-key flag or provider change)
791
+ """
720
792
  from src.licensing.tier_mapping import get_env_file_path, save_connector_credentials
721
793
 
722
794
  env_path = get_env_file_path()
@@ -725,28 +797,45 @@ def _apply_model_switch(provider_name: str, model_id: str) -> None:
725
797
  "YCB_LLM_MODEL": model_id,
726
798
  }
727
799
 
728
- # Cloud providers: only prompt for key if not already saved
800
+ # Cloud providers: prompt for key if missing OR if user asked to update it
729
801
  if provider_name != "local":
730
802
  key_var = _provider_key_env_var(provider_name)
731
803
  existing_key = os.environ.get(key_var, "").strip()
732
- if not existing_key:
733
- provider_labels = {
734
- "openai": "OpenAI",
735
- "anthropic": "Anthropic",
736
- "openrouter": "OpenRouter",
737
- }
738
- label = provider_labels.get(provider_name, provider_name)
739
- import click as _click
740
- api_key = _click.prompt(
741
- f" Enter your {label} API key",
742
- hide_input=True,
743
- ).strip()
744
- if not api_key:
745
- console.print("[bold red]Error:[/bold red] API key cannot be empty.")
746
- return
747
- credentials[key_var] = api_key
804
+ provider_labels = {
805
+ "openai": "OpenAI",
806
+ "anthropic": "Anthropic",
807
+ "openrouter": "OpenRouter",
808
+ }
809
+ label = provider_labels.get(provider_name, provider_name)
810
+
811
+ if not existing_key or force_key_update:
812
+ if existing_key and force_key_update:
813
+ console.print(f" [dim]Updating {key_var} (press Enter to keep existing):[/dim]")
814
+ import click as _click
815
+ api_key = _click.prompt(
816
+ f" Enter your {label} API key",
817
+ hide_input=True,
818
+ default="",
819
+ show_default=False,
820
+ ).strip()
821
+ if api_key: # only overwrite if they entered something
822
+ credentials[key_var] = api_key
823
+ else:
824
+ console.print(f" [dim]Keeping existing {key_var}.[/dim]")
825
+ else:
826
+ import click as _click
827
+ api_key = _click.prompt(
828
+ f" Enter your {label} API key",
829
+ hide_input=True,
830
+ ).strip()
831
+ if not api_key:
832
+ console.print("[bold red]Error:[/bold red] API key cannot be empty.")
833
+ return
834
+ credentials[key_var] = api_key
748
835
  else:
749
- console.print(f" [dim]Using saved {_provider_key_env_var(provider_name)} (already configured)[/dim]")
836
+ # Mask the key for display: show first 8 chars + ***
837
+ masked = existing_key[:8] + "***" if len(existing_key) > 8 else "***"
838
+ console.print(f" [dim]Using saved {key_var}: {masked} (run with --update-key to change)[/dim]")
750
839
 
751
840
  save_connector_credentials("llm_provider", credentials, env_path)
752
841
 
@@ -764,20 +853,26 @@ def _apply_model_switch(provider_name: str, model_id: str) -> None:
764
853
  @click.argument("model_name", required=False, default=None)
765
854
  @click.option("--list", "list_only", is_flag=True, default=False,
766
855
  help="Print the model list without switching.")
767
- def switch_model(model_name, list_only):
856
+ @click.option("--update-key", "update_key", is_flag=True, default=False,
857
+ help="Re-prompt for the provider API key even if one is already saved.")
858
+ def switch_model(model_name, list_only, update_key):
768
859
  """Switch the active LLM model for --ask.
769
860
 
770
861
  MODEL_NAME: optional model string to switch directly (e.g. gpt-5.6-terra,
771
862
  claude-sonnet-5, gemma4:e4b). Omit to show the interactive menu.
772
863
  Any string containing '/' that is not a known named model is treated as a
773
864
  custom OpenRouter model string.
865
+
866
+ To update/change your saved API key for a provider, run:
867
+ ycb switch model --update-key
774
868
  """
775
869
  from src.qa.providers import MODELS, resolve_model_entry
776
870
 
871
+ current_provider = os.environ.get("YCB_LLM_PROVIDER", "local")
872
+
777
873
  # ── --list: just print, don't switch ────────────────────────────────
778
874
  if list_only:
779
875
  _print_model_menu()
780
- current_provider = os.environ.get("YCB_LLM_PROVIDER", "local")
781
876
  current_model = os.environ.get("YCB_LLM_MODEL", "gemma4:e4b")
782
877
  console.print(
783
878
  f"[dim]Current: [bold]{current_model}[/bold] via {current_provider}[/dim]"
@@ -789,7 +884,10 @@ def switch_model(model_name, list_only):
789
884
  # Check named models first
790
885
  entry = resolve_model_entry(model_name)
791
886
  if entry:
792
- _apply_model_switch(entry.provider, entry.model_id)
887
+ # Auto-force key update when switching to a different provider
888
+ switching_provider = entry.provider != current_provider
889
+ _apply_model_switch(entry.provider, entry.model_id,
890
+ force_key_update=update_key or switching_provider)
793
891
  return
794
892
 
795
893
  # Treat any slug with '/' as a custom OpenRouter model
@@ -797,7 +895,9 @@ def switch_model(model_name, list_only):
797
895
  console.print(
798
896
  f" [dim]Treating '{model_name}' as a custom OpenRouter model.[/dim]"
799
897
  )
800
- _apply_model_switch("openrouter", model_name)
898
+ switching_provider = "openrouter" != current_provider
899
+ _apply_model_switch("openrouter", model_name,
900
+ force_key_update=update_key or switching_provider)
801
901
  return
802
902
 
803
903
  # Unknown string and no '/'
@@ -846,10 +946,14 @@ def switch_model(model_name, list_only):
846
946
  console.print(" [yellow]Model string cannot be empty.[/yellow]")
847
947
  continue
848
948
 
849
- _apply_model_switch("openrouter", custom_model)
949
+ switching_provider = "openrouter" != current_provider
950
+ _apply_model_switch("openrouter", custom_model,
951
+ force_key_update=update_key or switching_provider)
850
952
  return
851
953
 
852
- _apply_model_switch(entry.provider, entry.model_id)
954
+ switching_provider = entry.provider != current_provider
955
+ _apply_model_switch(entry.provider, entry.model_id,
956
+ force_key_update=update_key or switching_provider)
853
957
  return
854
958
 
855
959
 
@@ -39,7 +39,7 @@ class AirtableConnector(BaseConnector):
39
39
  def is_enabled(self) -> bool:
40
40
  if not super().is_enabled():
41
41
  return False
42
- return bool(self.api_token)
42
+ return self.are_valid_credentials(self.api_token)
43
43
 
44
44
  def _headers(self) -> Dict[str, str]:
45
45
  return {"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"}
@@ -22,7 +22,7 @@ class MixpanelConnector(BaseConnector):
22
22
  def is_enabled(self) -> bool:
23
23
  if not super().is_enabled():
24
24
  return False
25
- return bool(self.api_secret)
25
+ return self.are_valid_credentials(self.api_secret)
26
26
 
27
27
  def _auth(self) -> tuple:
28
28
  return (self.api_secret or "", "")
@@ -86,7 +86,7 @@ class AmplitudeConnector(BaseConnector):
86
86
  def is_enabled(self) -> bool:
87
87
  if not super().is_enabled():
88
88
  return False
89
- return bool(self.api_key and self.secret_key)
89
+ return self.are_valid_credentials(self.api_key, self.secret_key)
90
90
 
91
91
  def _auth(self) -> tuple:
92
92
  return (self.api_key or "", self.secret_key or "")
@@ -22,7 +22,7 @@ class AsanaConnector(BaseConnector):
22
22
  def is_enabled(self) -> bool:
23
23
  if not super().is_enabled():
24
24
  return False
25
- return bool(self.token)
25
+ return self.are_valid_credentials(self.token)
26
26
 
27
27
  def _headers(self) -> Dict[str, str]:
28
28
  return {
@@ -37,7 +37,7 @@ class AshbyConnector(BaseConnector):
37
37
  def is_enabled(self) -> bool:
38
38
  if not super().is_enabled():
39
39
  return False
40
- return bool(self.api_key)
40
+ return self.are_valid_credentials(self.api_key)
41
41
 
42
42
  def ingest(self, days_back: int = 30) -> List[RawDataItem]:
43
43
  if not self.is_enabled():
@@ -46,7 +46,7 @@ class BambooHRConnector(BaseConnector):
46
46
  def is_enabled(self) -> bool:
47
47
  if not super().is_enabled():
48
48
  return False
49
- return bool(self.api_key and self.subdomain)
49
+ return self.are_valid_credentials(self.api_key, self.subdomain)
50
50
 
51
51
  def ingest(self, days_back: int = 30) -> List[RawDataItem]:
52
52
  if not self.is_enabled():
@@ -21,6 +21,85 @@ class BaseConnector(ABC):
21
21
  """Fetch raw content from source API and map to RawDataItem schema"""
22
22
  pass
23
23
 
24
+ @staticmethod
25
+ def is_valid_credential(value: Optional[str]) -> bool:
26
+ """Check if a credential string is non-empty and not a dummy/template placeholder."""
27
+ if not value or not isinstance(value, str):
28
+ return False
29
+ val = value.strip()
30
+ if not val:
31
+ return False
32
+ val_lower = val.lower()
33
+
34
+ # Obvious placeholder indicators from .env.example / template files
35
+ placeholders = (
36
+ "your-user-token",
37
+ "your_user_token",
38
+ "your_token",
39
+ "your-token",
40
+ "your-company",
41
+ "your_company",
42
+ "yourcompany",
43
+ "your_personal_access_token",
44
+ "your_notion_integration_token",
45
+ "your_discord_bot_token",
46
+ "your_linear_key",
47
+ "your_azure",
48
+ "your_dropbox",
49
+ "your_mixpanel",
50
+ "your_amplitude",
51
+ "your_algolia",
52
+ "your_exa",
53
+ "your_perplexity",
54
+ "your_page_access_token",
55
+ "your_todoist",
56
+ "your_jira",
57
+ "your_asana",
58
+ "your_calendly",
59
+ "your_clickup",
60
+ "your_airtable",
61
+ "your_datadog",
62
+ "your_segment",
63
+ "your_front",
64
+ "your_zoom",
65
+ "your_x_twitter",
66
+ "your_hubspot",
67
+ "your_salesforce",
68
+ "your_monday",
69
+ "your_basecamp",
70
+ "your_ashby",
71
+ "your_bamboohr",
72
+ "your_deel",
73
+ "your_rippling",
74
+ "your_email",
75
+ "your_project_id",
76
+ "your_team_id",
77
+ "your_workspace_gid",
78
+ "owner/repo1",
79
+ "group/project1",
80
+ "you@yourcompany",
81
+ "placeholder",
82
+ "appxxxxxxxx",
83
+ "sk-...",
84
+ "sk-ant-...",
85
+ "sk-or-...",
86
+ "eaag...",
87
+ )
88
+ if any(p in val_lower for p in placeholders):
89
+ return False
90
+ if val_lower.startswith("your_") or val_lower.startswith("your-"):
91
+ return False
92
+ if "your_" in val_lower or "-your-" in val_lower or "-your_" in val_lower:
93
+ return False
94
+ return True
95
+
96
+ @classmethod
97
+ def are_valid_credentials(cls, *values: Optional[str]) -> bool:
98
+ """Return True if all provided credential values are non-empty and not dummy placeholders."""
99
+ if not values:
100
+ return False
101
+ return all(cls.is_valid_credential(v) for v in values)
102
+
24
103
  def is_enabled(self) -> bool:
25
104
  """Check if connector is enabled via CONNECTORS_ENABLED environment variable or credentials"""
26
105
  enabled_env = os.getenv("CONNECTORS_ENABLED", "")
@@ -51,7 +51,7 @@ class BasecampConnector(BaseConnector):
51
51
  def is_enabled(self) -> bool:
52
52
  if not super().is_enabled():
53
53
  return False
54
- return bool(self.access_token and self.account_id)
54
+ return self.are_valid_credentials(self.access_token, self.account_id)
55
55
 
56
56
  def _headers(self) -> Dict[str, str]:
57
57
  return {
@@ -23,7 +23,7 @@ class CalendlyConnector(BaseConnector):
23
23
  def is_enabled(self) -> bool:
24
24
  if not super().is_enabled():
25
25
  return False
26
- return bool(self.token)
26
+ return self.are_valid_credentials(self.token)
27
27
 
28
28
  def _headers(self) -> Dict[str, str]:
29
29
  return {
@@ -22,7 +22,7 @@ class ClickUpConnector(BaseConnector):
22
22
  def is_enabled(self) -> bool:
23
23
  if not super().is_enabled():
24
24
  return False
25
- return bool(self.api_token)
25
+ return self.are_valid_credentials(self.api_token)
26
26
 
27
27
  def _headers(self) -> Dict[str, str]:
28
28
  return {
@@ -35,7 +35,7 @@ class DatadogConnector(BaseConnector):
35
35
  def is_enabled(self) -> bool:
36
36
  if not super().is_enabled():
37
37
  return False
38
- return bool(self.api_key and self.app_key)
38
+ return self.are_valid_credentials(self.api_key, self.app_key)
39
39
 
40
40
  def _headers(self) -> Dict[str, str]:
41
41
  return {
@@ -34,7 +34,7 @@ class DeelConnector(BaseConnector):
34
34
  def is_enabled(self) -> bool:
35
35
  if not super().is_enabled():
36
36
  return False
37
- return bool(self.api_token)
37
+ return self.are_valid_credentials(self.api_token)
38
38
 
39
39
  def ingest(self, days_back: int = 30) -> List[RawDataItem]:
40
40
  if not self.is_enabled():
@@ -29,7 +29,7 @@ class DiscordConnector(BaseConnector):
29
29
  def is_enabled(self) -> bool:
30
30
  if not super().is_enabled():
31
31
  return False
32
- return bool(self.token)
32
+ return self.are_valid_credentials(self.token)
33
33
 
34
34
  def _headers(self) -> Dict[str, str]:
35
35
  return {
@@ -25,7 +25,7 @@ class DropboxConnector(BaseConnector):
25
25
  def is_enabled(self) -> bool:
26
26
  if not super().is_enabled():
27
27
  return False
28
- return bool(self.token)
28
+ return self.are_valid_credentials(self.token)
29
29
 
30
30
  def _api_headers(self) -> Dict[str, str]:
31
31
  return {
@@ -22,7 +22,7 @@ class FacebookConnector(BaseConnector):
22
22
  def is_enabled(self) -> bool:
23
23
  if not super().is_enabled():
24
24
  return False
25
- return bool(self.page_token)
25
+ return self.are_valid_credentials(self.page_token)
26
26
 
27
27
  def _params(self, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
28
28
  p = {"access_token": self.page_token or ""}
@@ -28,7 +28,7 @@ class FrontConnector(BaseConnector):
28
28
  def is_enabled(self) -> bool:
29
29
  if not super().is_enabled():
30
30
  return False
31
- return bool(self.api_token)
31
+ return self.are_valid_credentials(self.api_token)
32
32
 
33
33
  def _headers(self) -> Dict[str, str]:
34
34
  return {
@@ -28,7 +28,7 @@ class GitHubConnector(BaseConnector):
28
28
  def is_enabled(self) -> bool:
29
29
  if not super().is_enabled():
30
30
  return False
31
- return bool(self.token)
31
+ return self.are_valid_credentials(self.token)
32
32
 
33
33
  def _headers(self) -> Dict[str, str]:
34
34
  headers = {
@@ -29,7 +29,7 @@ class GitLabConnector(BaseConnector):
29
29
  def is_enabled(self) -> bool:
30
30
  if not super().is_enabled():
31
31
  return False
32
- return bool(self.token)
32
+ return self.are_valid_credentials(self.token)
33
33
 
34
34
  def _headers(self) -> Dict[str, str]:
35
35
  return {
@@ -57,7 +57,7 @@ class HubSpotConnector(BaseConnector):
57
57
  def is_enabled(self) -> bool:
58
58
  if not super().is_enabled():
59
59
  return False
60
- return bool(self.access_token)
60
+ return self.are_valid_credentials(self.access_token)
61
61
 
62
62
  def _headers(self) -> Dict[str, str]:
63
63
  return {
@@ -28,7 +28,7 @@ class JiraConnector(BaseConnector):
28
28
  def is_enabled(self) -> bool:
29
29
  if not super().is_enabled():
30
30
  return False
31
- return bool(self.domain and self.email and self.api_token)
31
+ return self.are_valid_credentials(self.domain, self.email, self.api_token)
32
32
 
33
33
  def _headers(self) -> Dict[str, str]:
34
34
  auth_str = f"{self.email}:{self.api_token}"
@@ -60,7 +60,7 @@ class LinearConnector(BaseConnector):
60
60
  def is_enabled(self) -> bool:
61
61
  if not super().is_enabled():
62
62
  return False
63
- return bool(self.api_key)
63
+ return self.are_valid_credentials(self.api_key)
64
64
 
65
65
  def _headers(self) -> Dict[str, str]:
66
66
  return {
@@ -35,7 +35,7 @@ class MondayConnector(BaseConnector):
35
35
  def is_enabled(self) -> bool:
36
36
  if not super().is_enabled():
37
37
  return False
38
- return bool(self.api_token)
38
+ return self.are_valid_credentials(self.api_token)
39
39
 
40
40
  def _headers(self) -> Dict[str, str]:
41
41
  return {
@@ -23,7 +23,7 @@ class NotionConnector(BaseConnector):
23
23
  def is_enabled(self) -> bool:
24
24
  if not super().is_enabled():
25
25
  return False
26
- return bool(self.token)
26
+ return self.are_valid_credentials(self.token)
27
27
 
28
28
  def _headers(self) -> Dict[str, str]:
29
29
  return {
@@ -33,8 +33,8 @@ class OutlookTeamsConnector(BaseConnector):
33
33
  def is_enabled(self) -> bool:
34
34
  if not super().is_enabled():
35
35
  return False
36
- # Enabled if client_id and client_secret are configured
37
- return bool(self.client_id and self.client_secret)
36
+ # Enabled if client_id and client_secret are configured and non-placeholder
37
+ return self.are_valid_credentials(self.client_id, self.client_secret)
38
38
 
39
39
  def _get_access_token(self) -> Optional[str]:
40
40
  """Obtain Graph API OAuth2 token using client credentials flow"""
@@ -40,7 +40,7 @@ class RipplingConnector(BaseConnector):
40
40
  def is_enabled(self) -> bool:
41
41
  if not super().is_enabled():
42
42
  return False
43
- return bool(self.api_token)
43
+ return self.are_valid_credentials(self.api_token)
44
44
 
45
45
  def ingest(self, days_back: int = 30) -> List[RawDataItem]:
46
46
  if not self.is_enabled():
@@ -76,7 +76,7 @@ class SalesforceConnector(BaseConnector):
76
76
  def is_enabled(self) -> bool:
77
77
  if not super().is_enabled():
78
78
  return False
79
- return bool(self.client_id and self.client_secret)
79
+ return self.are_valid_credentials(self.client_id, self.client_secret)
80
80
 
81
81
  def _authenticate(self) -> None:
82
82
  """Exchange client credentials for an access token and instance URL."""
@@ -22,7 +22,7 @@ class AlgoliaConnector(BaseConnector):
22
22
  def is_enabled(self) -> bool:
23
23
  if not super().is_enabled():
24
24
  return False
25
- return bool(self.app_id and self.api_key and self.index_name)
25
+ return self.are_valid_credentials(self.app_id, self.api_key, self.index_name)
26
26
 
27
27
  def _headers(self) -> Dict[str, str]:
28
28
  return {
@@ -95,7 +95,7 @@ class ExaConnector(BaseConnector):
95
95
  def is_enabled(self) -> bool:
96
96
  if not super().is_enabled():
97
97
  return False
98
- return bool(self.api_key)
98
+ return self.are_valid_credentials(self.api_key)
99
99
 
100
100
  def _headers(self) -> Dict[str, str]:
101
101
  return {
@@ -172,7 +172,7 @@ class PerplexityConnector(BaseConnector):
172
172
  def is_enabled(self) -> bool:
173
173
  if not super().is_enabled():
174
174
  return False
175
- return bool(self.api_key)
175
+ return self.are_valid_credentials(self.api_key)
176
176
 
177
177
  def _headers(self) -> Dict[str, str]:
178
178
  return {
@@ -31,7 +31,7 @@ class SegmentConnector(BaseConnector):
31
31
  def is_enabled(self) -> bool:
32
32
  if not super().is_enabled():
33
33
  return False
34
- return bool(self.access_token)
34
+ return self.are_valid_credentials(self.access_token)
35
35
 
36
36
  def _headers(self) -> Dict[str, str]:
37
37
  return {
@@ -30,8 +30,12 @@ class SlackConnector(BaseConnector):
30
30
  def is_enabled(self) -> bool:
31
31
  if not super().is_enabled():
32
32
  return False
33
- # Only enabled if a valid per-user token (xoxp-) is configured
34
- return bool(self.user_token and self.user_token.startswith("xoxp-"))
33
+ # Only enabled if a valid per-user token (xoxp-) is configured and not a dummy/template placeholder
34
+ return bool(
35
+ self.user_token
36
+ and self.user_token.startswith("xoxp-")
37
+ and self.is_valid_credential(self.user_token)
38
+ )
35
39
 
36
40
  def ingest(self, days_back: int = 30) -> List[RawDataItem]:
37
41
  return self.ingest_slack(days_back=days_back)
@@ -24,7 +24,7 @@ class TodoistConnector(BaseConnector):
24
24
  def is_enabled(self) -> bool:
25
25
  if not super().is_enabled():
26
26
  return False
27
- return bool(self.api_token)
27
+ return self.are_valid_credentials(self.api_token)
28
28
 
29
29
  def _headers(self) -> Dict[str, str]:
30
30
  return {
@@ -67,7 +67,7 @@ class TwitterConnector(BaseConnector):
67
67
  def is_enabled(self) -> bool:
68
68
  if not super().is_enabled():
69
69
  return False
70
- return bool(self.bearer_token)
70
+ return self.are_valid_credentials(self.bearer_token)
71
71
 
72
72
  def _headers(self) -> Dict[str, str]:
73
73
  return {"Authorization": f"Bearer {self.bearer_token}"}
@@ -42,7 +42,7 @@ class ZoomConnector(BaseConnector):
42
42
  def is_enabled(self) -> bool:
43
43
  if not super().is_enabled():
44
44
  return False
45
- return bool(self.account_id and self.client_id and self.client_secret)
45
+ return self.are_valid_credentials(self.account_id, self.client_id, self.client_secret)
46
46
 
47
47
  def _fetch_access_token(self) -> str:
48
48
  """Exchange Server-to-Server credentials for a bearer access token."""
@@ -40,18 +40,11 @@ TIER_CONNECTORS: Dict[str, List[str]] = {
40
40
  "hubspot", "salesforce", "monday", "basecamp", "ashby",
41
41
  "bamboohr", "deel", "rippling",
42
42
  ],
43
- "test": [
44
- "slack", "google_docs", "github", "notion", "discord",
45
- "google_sheets", "linear", "outlook", "gitlab", "dropbox",
46
- "mixpanel", "amplitude", "algolia", "exa", "perplexity",
47
- "facebook", "gmail", "google_drive", "todoist", "web_crawler",
48
- "jira", "asana", "calendly", "clickup", "airtable",
49
- "datadog", "segment", "front", "zoom", "twitter",
50
- "hubspot", "salesforce", "monday", "basecamp", "ashby",
51
- "bamboohr", "deel", "rippling",
52
- ],
53
43
  }
54
44
 
45
+ # Recognized license tiers (including 'test' which receives full enterprise-level access)
46
+ VALID_TIERS = set(TIER_CONNECTORS.keys()) | {"test"}
47
+
55
48
  # Categorized grouping of all 38 connectors for clean CLI status display
56
49
  CONNECTOR_CATEGORIES: Dict[str, List[str]] = {
57
50
  "Communication & Chat": ["slack", "discord", "outlook", "front", "zoom", "twitter", "facebook", "gmail"],
@@ -104,6 +97,110 @@ CONNECTOR_ENV_VARS: Dict[str, List[str]] = {
104
97
  "rippling": ["RIPPLING_API_TOKEN"],
105
98
  }
106
99
 
100
+ # Where to get credentials — env var -> (label, url)
101
+ # Shown as a hint before each credential prompt in `ycb connect`.
102
+ CONNECTOR_CREDENTIAL_LINKS: Dict[str, Tuple[str, str]] = {
103
+ # Slack
104
+ "SLACK_CLIENT_ID": ("Slack App Dashboard", "https://api.slack.com/apps"),
105
+ "SLACK_CLIENT_SECRET": ("Slack App Dashboard", "https://api.slack.com/apps"),
106
+ "SLACK_USER_TOKEN": ("Slack App OAuth & Permissions", "https://api.slack.com/apps"),
107
+ # Google (shared across google_docs, gmail, sheets, drive)
108
+ "GOOGLE_CREDENTIALS_PATH": ("Google Cloud Console — OAuth credentials (download as credentials.json)",
109
+ "https://console.cloud.google.com/apis/credentials"),
110
+ # GitHub
111
+ "GITHUB_TOKEN": ("GitHub Personal Access Tokens", "https://github.com/settings/tokens"),
112
+ "GITHUB_REPOS": ("Your GitHub repo list", "e.g. myorg/repo1,myorg/repo2"),
113
+ # Notion
114
+ "NOTION_TOKEN": ("Notion My Integrations", "https://www.notion.so/my-integrations"),
115
+ # Discord
116
+ "DISCORD_BOT_TOKEN": ("Discord Developer Portal", "https://discord.com/developers/applications"),
117
+ "DISCORD_GUILD_IDS": ("Your Discord Server IDs", "Right-click server → Copy Server ID (enable Developer Mode first)"),
118
+ # Linear
119
+ "LINEAR_API_KEY": ("Linear API Settings", "https://linear.app/settings/api"),
120
+ # Outlook / Microsoft Graph
121
+ "MS_CLIENT_ID": ("Azure App Registrations", "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps"),
122
+ "MS_CLIENT_SECRET": ("Azure App — Certificates & Secrets", "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps"),
123
+ "MS_TENANT_ID": ("Azure Active Directory — Tenant ID", "https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade"),
124
+ # GitLab
125
+ "GITLAB_TOKEN": ("GitLab Personal Access Tokens", "https://gitlab.com/-/profile/personal_access_tokens"),
126
+ "GITLAB_URL": ("Your GitLab instance URL", "e.g. https://gitlab.com or your self-hosted URL"),
127
+ "GITLAB_PROJECTS": ("Your GitLab project list", "e.g. mygroup/project1,mygroup/project2"),
128
+ # Dropbox
129
+ "DROPBOX_TOKEN": ("Dropbox App Console", "https://www.dropbox.com/developers/apps"),
130
+ # Mixpanel
131
+ "MIXPANEL_API_SECRET": ("Mixpanel Project Settings — Service Account", "https://mixpanel.com/settings/project"),
132
+ "MIXPANEL_PROJECT_ID": ("Mixpanel Project Settings", "https://mixpanel.com/settings/project"),
133
+ # Amplitude
134
+ "AMPLITUDE_API_KEY": ("Amplitude Project Settings", "https://analytics.amplitude.com/login"),
135
+ "AMPLITUDE_SECRET_KEY": ("Amplitude Project Settings", "https://analytics.amplitude.com/login"),
136
+ # Algolia
137
+ "ALGOLIA_APP_ID": ("Algolia Dashboard — API Keys", "https://dashboard.algolia.com/account/api-keys"),
138
+ "ALGOLIA_API_KEY": ("Algolia Dashboard — API Keys", "https://dashboard.algolia.com/account/api-keys"),
139
+ "ALGOLIA_INDEX_NAME": ("Your Algolia index name", "Found in Algolia Dashboard → Search → Indices"),
140
+ # Exa
141
+ "EXA_API_KEY": ("Exa Dashboard", "https://dashboard.exa.ai/api-keys"),
142
+ # Perplexity
143
+ "PERPLEXITY_API_KEY": ("Perplexity API Settings", "https://www.perplexity.ai/settings/api"),
144
+ # Facebook
145
+ "FACEBOOK_PAGE_TOKEN": ("Facebook Developer Portal — Page Access Token", "https://developers.facebook.com/apps"),
146
+ "FACEBOOK_PAGE_ID": ("Your Facebook Page ID", "Found in Facebook Page → About or Page Settings"),
147
+ # Todoist
148
+ "TODOIST_API_TOKEN": ("Todoist Integrations Settings", "https://app.todoist.com/app/settings/integrations/developer"),
149
+ # Web Crawler
150
+ "WEB_CRAWLER_START_URLS": ("Comma-separated list of URLs to crawl", "e.g. https://docs.yourcompany.com,https://yourcompany.com/blog"),
151
+ # Jira / Atlassian
152
+ "JIRA_DOMAIN": ("Your Jira workspace domain", "e.g. yourcompany.atlassian.net"),
153
+ "JIRA_EMAIL": ("Your Atlassian account email", "The email you use to log in to Jira"),
154
+ "JIRA_API_TOKEN": ("Atlassian API Tokens", "https://id.atlassian.com/manage-profile/security/api-tokens"),
155
+ # Asana
156
+ "ASANA_TOKEN": ("Asana Developer Console — Personal Access Token", "https://app.asana.com/0/developer-console"),
157
+ "ASANA_WORKSPACE_GID": ("Your Asana Workspace GID", "Found via GET https://app.asana.com/api/1.0/workspaces"),
158
+ # Calendly
159
+ "CALENDLY_TOKEN": ("Calendly Developer Portal — Personal Access Token", "https://developer.calendly.com/how-to-authenticate-with-personal-access-tokens"),
160
+ "CALENDLY_USER_URI": ("Your Calendly User URI", "Found via GET https://api.calendly.com/users/me"),
161
+ # ClickUp
162
+ "CLICKUP_API_TOKEN": ("ClickUp Settings — Apps", "https://app.clickup.com/settings/apps"),
163
+ "CLICKUP_TEAM_ID": ("Your ClickUp Team ID", "Found in ClickUp URL: app.clickup.com/<team_id>/"),
164
+ # Airtable
165
+ "AIRTABLE_TOKEN": ("Airtable Developer Hub — Personal Access Tokens", "https://airtable.com/create/tokens"),
166
+ "AIRTABLE_BASE_IDS": ("Your Airtable Base IDs", "Found in Airtable API docs page for each base: airtable.com/appXXXXXX/api/docs"),
167
+ # Datadog
168
+ "DATADOG_API_KEY": ("Datadog Organization Settings — API Keys", "https://app.datadoghq.com/organization-settings/api-keys"),
169
+ "DATADOG_APP_KEY": ("Datadog Organization Settings — Application Keys", "https://app.datadoghq.com/organization-settings/application-keys"),
170
+ # Segment
171
+ "SEGMENT_PERSONAS_TOKEN": ("Segment App — Access Tokens", "https://app.segment.com/goto-my-workspace/settings/access-management"),
172
+ "SEGMENT_SPACE_ID": ("Your Segment Space ID", "Found in Segment → Engage → Settings → API Access"),
173
+ # Front
174
+ "FRONT_API_TOKEN": ("Front App Settings — API Tokens", "https://app.frontapp.com/settings/tools/api"),
175
+ # Zoom
176
+ "ZOOM_ACCOUNT_ID": ("Zoom Marketplace — Server-to-Server OAuth App", "https://marketplace.zoom.us/develop/create"),
177
+ "ZOOM_CLIENT_ID": ("Zoom Marketplace — Server-to-Server OAuth App", "https://marketplace.zoom.us/develop/create"),
178
+ "ZOOM_CLIENT_SECRET": ("Zoom Marketplace — Server-to-Server OAuth App", "https://marketplace.zoom.us/develop/create"),
179
+ # Twitter / X
180
+ "TWITTER_BEARER_TOKEN": ("Twitter Developer Portal — Project/App Keys", "https://developer.twitter.com/en/portal/dashboard"),
181
+ "TWITTER_SEARCH_QUERIES": ("Comma-separated search queries", "e.g. @yourcompany,#yourproduct"),
182
+ # HubSpot
183
+ "HUBSPOT_ACCESS_TOKEN": ("HubSpot Private App Access Token", "https://app.hubspot.com/private-apps"),
184
+ # Salesforce
185
+ "SALESFORCE_CLIENT_ID": ("Salesforce Connected App Consumer Key", "Setup → Apps → App Manager → New Connected App"),
186
+ "SALESFORCE_CLIENT_SECRET": ("Salesforce Connected App Consumer Secret", "Setup → Apps → App Manager → New Connected App"),
187
+ "SALESFORCE_DOMAIN": ("Your Salesforce domain", "e.g. yourcompany.my.salesforce.com"),
188
+ # Monday
189
+ "MONDAY_API_TOKEN": ("Monday.com Developer Center — API Token", "https://auth.monday.com/users/sign_in (Profile → Developers → API)"),
190
+ # Basecamp
191
+ "BASECAMP_ACCESS_TOKEN": ("Basecamp — Launchpad OAuth / Personal Tokens", "https://launchpad.37signals.com/"),
192
+ "BASECAMP_ACCOUNT_ID": ("Your Basecamp Account ID", "Found in your Basecamp URL: 3.basecamp.com/<account_id>/"),
193
+ # Ashby
194
+ "ASHBY_API_KEY": ("Ashby Settings — API Keys", "https://app.ashbyhq.com/admin/api/keys"),
195
+ # BambooHR
196
+ "BAMBOOHR_API_KEY": ("BambooHR API Access — Generate API Key", "https://app.bamboohr.com/settings/permissions/"),
197
+ "BAMBOOHR_SUBDOMAIN": ("Your BambooHR subdomain", "e.g. if your URL is mycompany.bamboohr.com, enter 'mycompany'"),
198
+ # Deel
199
+ "DEEL_API_TOKEN": ("Deel API Settings", "https://app.deel.com/settings/api"),
200
+ # Rippling
201
+ "RIPPLING_API_TOKEN": ("Rippling API Credentials", "https://app.rippling.com/api"),
202
+ }
203
+
107
204
 
108
205
  def get_cached_plan_tier(cache_dir: Optional[str] = None, expected_key: Optional[str] = None) -> str:
109
206
  """Read the plan_tier from ~/.company-brain/license.json. Defaults to 'solo' if unconfigured.
@@ -165,8 +262,9 @@ def get_cached_plan_tier(cache_dir: Optional[str] = None, expected_key: Optional
165
262
  except Exception:
166
263
  pass
167
264
 
168
- if not is_stale_or_expired and tier in TIER_CONNECTORS:
169
- return tier
265
+ tier_lower = (tier or "").lower()
266
+ if not is_stale_or_expired and tier_lower in VALID_TIERS:
267
+ return tier_lower
170
268
 
171
269
  # 3. Mismatch detected or no matching cache entry for resolved_key
172
270
  if resolved_key:
@@ -182,7 +280,7 @@ def get_cached_plan_tier(cache_dir: Optional[str] = None, expected_key: Optional
182
280
  )
183
281
  if val_res and val_res.is_valid and val_res.plan_tier:
184
282
  healed_tier = val_res.plan_tier.lower()
185
- if healed_tier in TIER_CONNECTORS:
283
+ if healed_tier in VALID_TIERS:
186
284
  return healed_tier
187
285
  elif val_res and val_res.status == "expired":
188
286
  try:
@@ -217,7 +315,8 @@ def get_cached_plan_tier(cache_dir: Optional[str] = None, expected_key: Optional
217
315
 
218
316
  def is_connector_permitted(connector_name: str, plan_tier: str) -> bool:
219
317
  """Check if a connector is permitted in the given plan tier."""
220
- permitted = TIER_CONNECTORS.get(plan_tier.lower(), [])
318
+ effective_tier = "enterprise" if plan_tier.lower() == "test" else plan_tier.lower()
319
+ permitted = TIER_CONNECTORS.get(effective_tier, [])
221
320
  return connector_name.lower() in permitted
222
321
 
223
322
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes