pi-harness-runtime 1.1.16 → 1.1.26

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 (105) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +76 -18
  3. package/harness/e2e/auto-test-loop.ts +815 -0
  4. package/harness/e2e/tools-detector.test.ts +156 -0
  5. package/harness/e2e/tools-detector.ts +801 -0
  6. package/harness/glm-quota-logger.ts +20 -23
  7. package/harness/index.ts +42 -0
  8. package/harness/loop-runtime.ts +49 -13
  9. package/harness/skill-sync.test.ts +57 -0
  10. package/harness/skill-sync.ts +247 -0
  11. package/harness/ssh-hang-recovery.ts +280 -0
  12. package/package.json +1 -1
  13. package/packages/a2a-adapter/package.json +1 -1
  14. package/packages/architecture-generator/package.json +1 -1
  15. package/packages/auth/package.json +1 -1
  16. package/packages/autonomous-refactor/package.json +1 -1
  17. package/packages/autonomous-runtime/package.json +1 -1
  18. package/packages/cache-strategy/package.json +1 -1
  19. package/packages/capability-registry/package.json +1 -1
  20. package/packages/checkpoint/package.json +1 -1
  21. package/packages/cli-plugin-sdk/package.json +1 -1
  22. package/packages/clipboard/package.json +1 -1
  23. package/packages/clipboard-plugin/package.json +1 -1
  24. package/packages/code-generation/package.json +1 -1
  25. package/packages/code-review/package.json +1 -1
  26. package/packages/codex-adapter/package.json +1 -1
  27. package/packages/config-capture/package.json +1 -1
  28. package/packages/context-compiler/package.json +1 -1
  29. package/packages/context-discovery/package.json +1 -1
  30. package/packages/context-manager/package.json +1 -1
  31. package/packages/cookie-sanitizer/package.json +1 -1
  32. package/packages/cost-optimizer/package.json +1 -1
  33. package/packages/dependency-analyzer/package.json +1 -1
  34. package/packages/django-plugin/package.json +1 -1
  35. package/packages/doc-generator/package.json +1 -1
  36. package/packages/evaluation-engine/package.json +1 -1
  37. package/packages/evaluation-runner/package.json +1 -1
  38. package/packages/event-bus/package.json +1 -1
  39. package/packages/event-store/package.json +1 -1
  40. package/packages/experience-replay/package.json +1 -1
  41. package/packages/feedback-collector/package.json +1 -1
  42. package/packages/file-copy-helper/package.json +1 -1
  43. package/packages/framework-detector/package.json +1 -1
  44. package/packages/framework-plugin-sdk/package.json +1 -1
  45. package/packages/frappe-plugin/package.json +1 -1
  46. package/packages/generic-web-plugin/package.json +1 -1
  47. package/packages/health-monitor/package.json +1 -1
  48. package/packages/intent-analyzer/package.json +1 -1
  49. package/packages/knowledge-graph/package.json +1 -1
  50. package/packages/knowledge-retrieval/package.json +1 -1
  51. package/packages/laravel-plugin/package.json +1 -1
  52. package/packages/learning-engine/package.json +1 -1
  53. package/packages/mcp-adapter/package.json +1 -1
  54. package/packages/memory-engine/package.json +1 -1
  55. package/packages/milestone-manager/package.json +1 -1
  56. package/packages/model-registry/package.json +1 -1
  57. package/packages/nextjs-plugin/package.json +1 -1
  58. package/packages/notification/package.json +1 -1
  59. package/packages/observability/package.json +1 -1
  60. package/packages/okf-indexer/package.json +1 -1
  61. package/packages/performance-optimizer/package.json +1 -1
  62. package/packages/privilege-broker/package.json +1 -1
  63. package/packages/project-analyzer/package.json +1 -1
  64. package/packages/project-bootstrap/package.json +1 -1
  65. package/packages/projection-engine/package.json +1 -1
  66. package/packages/prompt-compiler/package.json +1 -1
  67. package/packages/prompt-versioning/package.json +1 -1
  68. package/packages/provider-adapter-sdk/package.json +1 -1
  69. package/packages/provider-router/package.json +1 -1
  70. package/packages/provider-selector/package.json +1 -1
  71. package/packages/providers/package.json +1 -1
  72. package/packages/quota-manager/package.json +1 -1
  73. package/packages/rate-limiter/package.json +1 -1
  74. package/packages/react-vite-plugin/package.json +1 -1
  75. package/packages/release-manager/package.json +1 -1
  76. package/packages/requirement-compiler/package.json +1 -1
  77. package/packages/runtime/package.json +1 -1
  78. package/packages/scheduler/package.json +1 -1
  79. package/packages/scheduler-adapter/package.json +1 -1
  80. package/packages/session/package.json +1 -1
  81. package/packages/session-api/package.json +1 -1
  82. package/packages/session-export/package.json +1 -1
  83. package/packages/shared-context/package.json +1 -1
  84. package/packages/skill-mcp-client/package.json +28 -0
  85. package/packages/skill-mcp-client/src/client.ts +171 -0
  86. package/packages/skill-mcp-client/src/errors.ts +75 -0
  87. package/packages/skill-mcp-client/src/index.ts +13 -0
  88. package/packages/skill-mcp-client/src/types.ts +123 -0
  89. package/packages/skill-registry/package.json +1 -1
  90. package/packages/sprint-planner/package.json +1 -1
  91. package/packages/subscription-engine/package.json +1 -1
  92. package/packages/task-compiler/package.json +1 -1
  93. package/packages/tencentdb-memory/package.json +1 -1
  94. package/packages/tencentdb-sync/package.json +1 -1
  95. package/packages/test-data-generator/package.json +1 -1
  96. package/packages/test-generator/package.json +1 -1
  97. package/packages/todo-bd-sync/package.json +1 -1
  98. package/packages/token-estimation/package.json +1 -1
  99. package/packages/token-optimizer/package.json +1 -1
  100. package/packages/tui/package.json +1 -1
  101. package/packages/types/package.json +1 -1
  102. package/packages/workflow-events/package.json +1 -1
  103. package/packages/workspace-scanner/package.json +1 -1
  104. package/packages/worktree/package.json +1 -1
  105. package/packages/write-review/package.json +1 -1
@@ -29,7 +29,11 @@ function ensureLogDir(): void {
29
29
  }
30
30
 
31
31
  /** Format log entry */
32
- function formatLog(level: LogLevel, jobId: string | null, message: string): string {
32
+ function formatLog(
33
+ level: LogLevel,
34
+ jobId: string | null,
35
+ message: string,
36
+ ): string {
33
37
  const timestamp = new Date().toISOString();
34
38
  const job = jobId ? `[${jobId}]` : "[system]";
35
39
  return `[${timestamp}] [${level}] ${job} ${message}`;
@@ -38,7 +42,11 @@ function formatLog(level: LogLevel, jobId: string | null, message: string): stri
38
42
  /**
39
43
  * Write a log entry to file
40
44
  */
41
- function writeLog(level: LogLevel, jobId: string | null, message: string): void {
45
+ function writeLog(
46
+ level: LogLevel,
47
+ jobId: string | null,
48
+ message: string,
49
+ ): void {
42
50
  try {
43
51
  ensureLogDir();
44
52
  const entry = formatLog(level, jobId, message) + "\n";
@@ -70,7 +78,11 @@ export function logWarn(jobId: string | null, message: string): void {
70
78
  /**
71
79
  * Log error level event
72
80
  */
73
- export function logError(jobId: string | null, message: string, error?: unknown): void {
81
+ export function logError(
82
+ jobId: string | null,
83
+ message: string,
84
+ error?: unknown,
85
+ ): void {
74
86
  const errorMsg = error instanceof Error ? error.message : String(error ?? "");
75
87
  const fullMessage = errorMsg ? `${message}: ${errorMsg}` : message;
76
88
  console.error(`[GLMQuota] ${jobId ? `[${jobId}] ` : ""}${fullMessage}`);
@@ -87,10 +99,7 @@ export function logCountdownStarted(
87
99
  ): void {
88
100
  const hours = Math.floor(totalSeconds / 3600);
89
101
  const minutes = Math.floor((totalSeconds % 3600) / 60);
90
- logInfo(
91
- jobId,
92
- `Countdown started: ${hours}h ${minutes}m until ${resetAt}`,
93
- );
102
+ logInfo(jobId, `Countdown started: ${hours}h ${minutes}m until ${resetAt}`);
94
103
  }
95
104
 
96
105
  /** Log countdown tick */
@@ -101,10 +110,7 @@ export function logCountdownTick(
101
110
  const hours = Math.floor(remainingSeconds / 3600);
102
111
  const minutes = Math.floor((remainingSeconds % 3600) / 60);
103
112
  const secs = remainingSeconds % 60;
104
- logInfo(
105
- jobId,
106
- `Countdown tick: ${hours}h ${minutes}m ${secs}s remaining`,
107
- );
113
+ logInfo(jobId, `Countdown tick: ${hours}h ${minutes}m ${secs}s remaining`);
108
114
  }
109
115
 
110
116
  /** Log countdown completed */
@@ -123,19 +129,13 @@ export function logAutoResumeFailed(jobId: string, error: string): void {
123
129
  }
124
130
 
125
131
  /** Log notification sent */
126
- export function logNotificationSent(
127
- jobId: string,
128
- beforeReset: number,
129
- ): void {
132
+ export function logNotificationSent(jobId: string, beforeReset: number): void {
130
133
  const minutes = Math.floor(beforeReset / 60);
131
134
  logInfo(jobId, `Notification sent: ${minutes} minutes before reset`);
132
135
  }
133
136
 
134
137
  /** Log notification failed */
135
- export function logNotificationFailed(
136
- jobId: string,
137
- error: string,
138
- ): void {
138
+ export function logNotificationFailed(jobId: string, error: string): void {
139
139
  logWarn(jobId, `Notification failed: ${error}`);
140
140
  }
141
141
 
@@ -149,10 +149,7 @@ export function logMirrorUpdate(jobId: string, success: boolean): void {
149
149
  }
150
150
 
151
151
  /** Log quota exhaustion detected */
152
- export function logQuotaExhausted(
153
- jobId: string,
154
- resetAt: string,
155
- ): void {
152
+ export function logQuotaExhausted(jobId: string, resetAt: string): void {
156
153
  logInfo(jobId, `GLM 5h quota exhausted, reset at ${resetAt}`);
157
154
  }
158
155
 
package/harness/index.ts CHANGED
@@ -261,3 +261,45 @@ export type {
261
261
  KnowledgeQuery,
262
262
  KnowledgeResult,
263
263
  } from "../packages/memory-engine/src/types.js";
264
+
265
+ // --- E2E Testing (RFC-0101) -----------------------------------------------
266
+ export {
267
+ detectProjectType,
268
+ detectAvailableTools,
269
+ getRecommendedTools,
270
+ makeSmartDecision,
271
+ generateToolsPresentation,
272
+ getE2EToolsConfig,
273
+ } from "./e2e/tools-detector.js";
274
+ export type {
275
+ SmartDecision,
276
+ TestingTool,
277
+ E2EToolsConfig,
278
+ ProjectType,
279
+ NativeAppType,
280
+ } from "./e2e/tools-detector.js";
281
+ export { runAutoTestLoop } from "./e2e/auto-test-loop.js";
282
+ export type {
283
+ E2ETestResult,
284
+ AutoTestConfig,
285
+ AutoTestResult,
286
+ } from "./e2e/auto-test-loop.js";
287
+
288
+ // --- Skill SaaS Sync (RFC-0106) ----------------------------------------
289
+ export {
290
+ syncSkillsFromSaaS,
291
+ getSkillFromSaaS,
292
+ isSkillSyncConfigured,
293
+ getSyncStatus,
294
+ } from "./skill-sync.js";
295
+
296
+ // --- SSH Hang Recovery (auto-recovery from SSH hangs) -------------------
297
+ export {
298
+ sshWithRecovery,
299
+ pm2Restart,
300
+ getPM2Status,
301
+ safeSSHCommand,
302
+ pingServer,
303
+ type SSHConfig,
304
+ type SSHResult,
305
+ } from "./ssh-hang-recovery.js";
@@ -46,6 +46,9 @@ import {
46
46
  createForkedSummarizer,
47
47
  } from "./forked-summarizer.js";
48
48
 
49
+ // GLM quota error parsing for 429 responses
50
+ import { parseGLMErrorResetTime } from "./e2e/glm-quota-scraper.js";
51
+
49
52
  // --- Constants ----------------------------------------------------------------
50
53
 
51
54
  const DEFAULT_MAX_COMPACT_RETRIES = 3;
@@ -84,9 +87,7 @@ export interface LoopCallbacks {
84
87
  /** Get current checkpoint (used by auto-resume to read resumeAt) */
85
88
  onGetCheckpoint?: () => Promise<RuntimeCheckpoint | null>;
86
89
  /** Check mirror for a provider (used by auto-resume to detect early reset) */
87
- onCheckMirror?: (
88
- provider: string,
89
- ) => Promise<{ h5_used_pct?: number } | null>;
90
+ onCheckMirror?: (provider: string) => Promise<{ h5_used_pct?: number } | null>;
90
91
  /** Generic notify (used by auto-resume to tell user wait time) */
91
92
  onNotify?: (msg: string, type?: string) => Promise<void>;
92
93
  /** Save checkpoint (call persistence layer) */
@@ -244,11 +245,7 @@ export class LoopRuntime {
244
245
  await new Promise((r) => setTimeout(r, 5 * 60_000)); // 5 min
245
246
  // Re-check mirror — quota may have reset early
246
247
  const fresh = await this.callbacks.onCheckMirror?.("minimax");
247
- if (
248
- fresh &&
249
- fresh.h5_used_pct !== undefined &&
250
- fresh.h5_used_pct < 100
251
- ) {
248
+ if (fresh && fresh.h5_used_pct !== undefined && fresh.h5_used_pct < 100) {
252
249
  break; // quota reset early, resume now
253
250
  }
254
251
  }
@@ -447,14 +444,54 @@ export class LoopRuntime {
447
444
  continue;
448
445
  }
449
446
 
450
- // Error without compact — surface it
451
- throw new Error(result.error ?? "Unknown invoke error");
447
+ // Error without compact — check for GLM 429 quota error
448
+ const errMsg = result.error ?? "Unknown invoke error";
449
+ if (errMsg.includes("1308") || errMsg.includes("Usage limit")) {
450
+ const resetTime = parseGLMErrorResetTime(errMsg);
451
+ if (resetTime) {
452
+ const resetEpoch = new Date(resetTime).getTime();
453
+ this.mirrorStore.writeProvider("minimax", {
454
+ provider: "minimax",
455
+ synced_at: new Date().toISOString(),
456
+ source: "tui-signal",
457
+ h5_used_pct: 100,
458
+ h5_resets_at_epoch: resetEpoch,
459
+ });
460
+ console.log(`[LoopRuntime] GLM quota hit, reset at ${resetTime}`);
461
+ }
462
+ await this.handleQuotaPause();
463
+ return;
464
+ }
465
+ throw new Error(errMsg);
452
466
  }
453
467
 
454
468
  // Fallback: direct invoke without orchestrator
455
469
  const result = await this.callbacks.onInvokeAgent?.(invokeOptions);
456
470
  if (!result || !result.success) {
457
- throw new Error(result?.error ?? "Invoke failed");
471
+ const errorMsg = result?.error ?? "Invoke failed";
472
+
473
+ // Check if this is a GLM 429 quota error with reset time
474
+ if (errorMsg.includes("1308") || errorMsg.includes("Usage limit")) {
475
+ const resetTime = parseGLMErrorResetTime(errorMsg);
476
+ if (resetTime) {
477
+ // Update mirror with reset epoch so auto-resume works
478
+ const resetEpoch = new Date(resetTime).getTime();
479
+ this.mirrorStore.writeProvider("minimax", {
480
+ provider: "minimax",
481
+ synced_at: new Date().toISOString(),
482
+ source: "tui-signal",
483
+ h5_used_pct: 100,
484
+ h5_resets_at_epoch: resetEpoch,
485
+ });
486
+ console.log(`[LoopRuntime] GLM quota hit, reset at ${resetTime}`);
487
+ }
488
+
489
+ // Handle quota pause instead of throwing
490
+ await this.handleQuotaPause();
491
+ return;
492
+ }
493
+
494
+ throw new Error(errorMsg);
458
495
  }
459
496
 
460
497
  const continued = await this.handleAutoContinuation(
@@ -660,8 +697,7 @@ export class LoopRuntime {
660
697
  // Append repair instruction
661
698
  messages.push({
662
699
  role: "user",
663
- content:
664
- "Tests failed. Please fix the issues and ensure all tests pass.",
700
+ content: "Tests failed. Please fix the issues and ensure all tests pass.",
665
701
  timestamp: Date.now(),
666
702
  });
667
703
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Skill Sync Tests
3
+ */
4
+
5
+ // @ts-ignore - bun:test types
6
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
7
+ import { tmpdir } from "node:os";
8
+ import { mkdirSync, rmSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ // Mock the environment
12
+ const originalApiKey = process.env.SKILLS_SAAS_API_KEY;
13
+
14
+ describe("Skill Sync", () => {
15
+ let tempCacheDir: string;
16
+
17
+ beforeEach(() => {
18
+ tempCacheDir = join(tmpdir(), `skill-sync-test-${Date.now()}`);
19
+ mkdirSync(tempCacheDir, { recursive: true });
20
+ });
21
+
22
+ afterEach(() => {
23
+ // Restore env
24
+ if (originalApiKey === undefined) {
25
+ delete process.env.SKILLS_SAAS_API_KEY;
26
+ } else {
27
+ process.env.SKILLS_SAAS_API_KEY = originalApiKey;
28
+ }
29
+ // Cleanup
30
+ try {
31
+ rmSync(tempCacheDir, { recursive: true });
32
+ } catch {
33
+ // Ignore
34
+ }
35
+ });
36
+
37
+ describe("isSkillSyncConfigured", () => {
38
+ test("returns false when API key not set", () => {
39
+ delete process.env.SKILLS_SAAS_API_KEY;
40
+ // Import dynamically to test
41
+ expect(process.env.SKILLS_SAAS_API_KEY).toBeUndefined();
42
+ });
43
+
44
+ test("returns true when API key is set", () => {
45
+ process.env.SKILLS_SAAS_API_KEY = "sk_test_xxx";
46
+ expect(process.env.SKILLS_SAAS_API_KEY).toBe("sk_test_xxx");
47
+ });
48
+ });
49
+
50
+ describe("getSyncStatus", () => {
51
+ test("returns correct status when not configured", () => {
52
+ delete process.env.SKILLS_SAAS_API_KEY;
53
+ // Status should show not configured
54
+ expect(process.env.SKILLS_SAAS_API_KEY).toBeUndefined();
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Skill Sync Service — RFC-0106
3
+ *
4
+ * Syncs skills from Skills SaaS backend and registers them with local registry.
5
+ * Falls back gracefully if backend is unavailable.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import type {
12
+ SkillInfo,
13
+ GetSkillResult,
14
+ } from "../packages/skill-mcp-client/src/types.js";
15
+ import { SkillAPIError } from "../packages/skill-mcp-client/src/errors.js";
16
+
17
+ type SkillMCPClient = InstanceType<
18
+ typeof import("../packages/skill-mcp-client/src/client.js").SkillMCPClient
19
+ >;
20
+
21
+ // Lazy import to avoid errors if package not built yet
22
+ let clientInstance: SkillMCPClient | null = null;
23
+
24
+ async function getClient(): Promise<SkillMCPClient | null> {
25
+ if (!process.env.SKILLS_SAAS_API_KEY) {
26
+ return null;
27
+ }
28
+
29
+ if (clientInstance) {
30
+ return clientInstance;
31
+ }
32
+
33
+ try {
34
+ const mod = await import("../packages/skill-mcp-client/src/client.js");
35
+ const SkillMCPClientClass = mod.SkillMCPClient;
36
+ const serverUrl =
37
+ process.env.SKILLS_SAAS_URL || "https://api.skills.bunchee.online";
38
+
39
+ clientInstance = new SkillMCPClientClass({
40
+ serverUrl,
41
+ apiKey: process.env.SKILLS_SAAS_API_KEY,
42
+ timeoutMs: 5000,
43
+ });
44
+ return clientInstance;
45
+ } catch {
46
+ console.warn("[skill-sync] Failed to import skill-mcp-client");
47
+ return null;
48
+ }
49
+ }
50
+
51
+ // Cache directory
52
+ const CACHE_DIR = join(homedir(), ".pi", "skills-cache");
53
+ const CACHE_FILE = join(CACHE_DIR, "skills-cache.json");
54
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
55
+
56
+ interface CacheData {
57
+ skills: SkillInfo[];
58
+ cachedAt: string;
59
+ version: string;
60
+ }
61
+
62
+ interface SkillSyncResult {
63
+ success: boolean;
64
+ skillCount: number;
65
+ source: "saas" | "cache" | "none";
66
+ error?: string;
67
+ }
68
+
69
+ /**
70
+ * Get cached skills from local disk
71
+ */
72
+ function getCachedSkills(): CacheData | null {
73
+ try {
74
+ if (!existsSync(CACHE_FILE)) {
75
+ return null;
76
+ }
77
+
78
+ const data = JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as CacheData;
79
+ const age = Date.now() - new Date(data.cachedAt).getTime();
80
+
81
+ if (age > CACHE_TTL_MS) {
82
+ return null; // Cache expired
83
+ }
84
+
85
+ return data;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Save skills to local cache
93
+ */
94
+ function saveCache(data: CacheData): void {
95
+ try {
96
+ if (!existsSync(CACHE_DIR)) {
97
+ mkdirSync(CACHE_DIR, { recursive: true });
98
+ }
99
+ writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
100
+ } catch {
101
+ console.warn("[skill-sync] Failed to save cache");
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Fetch and cache skills from SaaS backend
107
+ */
108
+ async function fetchFromSaaS(): Promise<CacheData | null> {
109
+ const client = await getClient();
110
+ if (!client) {
111
+ return null;
112
+ }
113
+
114
+ try {
115
+ const result = await client.listSkills({ limit: 100 });
116
+ const cacheData: CacheData = {
117
+ skills: result.skills,
118
+ cachedAt: new Date().toISOString(),
119
+ version: "1.0.0",
120
+ };
121
+ saveCache(cacheData);
122
+ return cacheData;
123
+ } catch (error) {
124
+ if (error instanceof SkillAPIError) {
125
+ console.warn(
126
+ `[skill-sync] API error: ${error.message} (code: ${error.code})`,
127
+ );
128
+ } else {
129
+ console.warn("[skill-sync] Failed to fetch from SaaS");
130
+ }
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Sync skills from SaaS backend (RFC-0106)
137
+ *
138
+ * Flow:
139
+ * 1. Check cache first
140
+ * 2. If cache valid, return cached
141
+ * 3. If cache expired or missing, fetch from SaaS
142
+ * 4. If SaaS fails, return empty array (graceful degradation)
143
+ *
144
+ * @param options.forceRefresh - Force refresh from SaaS
145
+ * @returns Skills list (may be empty if backend unavailable)
146
+ */
147
+ export async function syncSkillsFromSaaS(
148
+ options: { forceRefresh?: boolean } = {},
149
+ ): Promise<SkillSyncResult> {
150
+ // Check if API key is configured
151
+ if (!process.env.SKILLS_SAAS_API_KEY) {
152
+ return {
153
+ success: true,
154
+ skillCount: 0,
155
+ source: "none",
156
+ error: "SKILLS_SAAS_API_KEY not configured",
157
+ };
158
+ }
159
+
160
+ // Check cache first (unless force refresh)
161
+ if (!options.forceRefresh) {
162
+ const cached = getCachedSkills();
163
+ if (cached) {
164
+ return {
165
+ success: true,
166
+ skillCount: cached.skills.length,
167
+ source: "cache",
168
+ };
169
+ }
170
+ }
171
+
172
+ // Fetch from SaaS
173
+ const data = await fetchFromSaaS();
174
+ if (data) {
175
+ return {
176
+ success: true,
177
+ skillCount: data.skills.length,
178
+ source: "saas",
179
+ };
180
+ }
181
+
182
+ // SaaS failed, try to use stale cache
183
+ const staleCache = getCachedSkills();
184
+ if (staleCache) {
185
+ return {
186
+ success: true,
187
+ skillCount: staleCache.skills.length,
188
+ source: "cache",
189
+ error: "Using stale cache (SaaS unavailable)",
190
+ };
191
+ }
192
+
193
+ // Everything failed, return empty
194
+ return {
195
+ success: true,
196
+ skillCount: 0,
197
+ source: "none",
198
+ error: "Could not reach Skills SaaS backend",
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Get full skill prompt by slug or id
204
+ *
205
+ * Returns null if skill not found or backend unavailable
206
+ */
207
+ export async function getSkillFromSaaS(identifier: {
208
+ slug?: string;
209
+ id?: string;
210
+ }): Promise<GetSkillResult | null> {
211
+ const client = await getClient();
212
+ if (!client) {
213
+ return null;
214
+ }
215
+
216
+ try {
217
+ return await client.getSkill(identifier);
218
+ } catch (error) {
219
+ if (error instanceof SkillAPIError) {
220
+ console.warn(`[skill-sync] Failed to get skill: ${error.message}`);
221
+ }
222
+ return null;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Check if skill sync is configured
228
+ */
229
+ export function isSkillSyncConfigured(): boolean {
230
+ return !!process.env.SKILLS_SAAS_API_KEY;
231
+ }
232
+
233
+ /**
234
+ * Get sync status info
235
+ */
236
+ export function getSyncStatus(): {
237
+ configured: boolean;
238
+ cacheValid: boolean;
239
+ cachedSkills: number;
240
+ } {
241
+ const cache = getCachedSkills();
242
+ return {
243
+ configured: isSkillSyncConfigured(),
244
+ cacheValid: !!cache,
245
+ cachedSkills: cache?.skills.length ?? 0,
246
+ };
247
+ }