@gajae-code/coding-agent 0.9.1 → 0.9.2

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 (62) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/cli/args.d.ts +1 -0
  3. package/dist/types/cli/notify-cli.d.ts +5 -0
  4. package/dist/types/cli.d.ts +3 -0
  5. package/dist/types/commands/launch.d.ts +3 -0
  6. package/dist/types/config/model-profile-activation.d.ts +7 -7
  7. package/dist/types/config/model-registry.d.ts +7 -3
  8. package/dist/types/config/model-resolver.d.ts +2 -2
  9. package/dist/types/config/settings-schema.d.ts +1 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
  12. package/dist/types/gjc-runtime/team-runtime.d.ts +9 -0
  13. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  14. package/dist/types/modes/components/thinking-selector.d.ts +6 -1
  15. package/dist/types/modes/types.d.ts +1 -0
  16. package/dist/types/notifications/config-commands.d.ts +29 -0
  17. package/dist/types/notifications/index.d.ts +12 -1
  18. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  19. package/dist/types/notifications/threaded-render.d.ts +2 -0
  20. package/dist/types/notifications/topic-registry.d.ts +4 -5
  21. package/dist/types/runtime-credential-selector.d.ts +7 -0
  22. package/dist/types/sdk.d.ts +7 -1
  23. package/dist/types/session/agent-session.d.ts +6 -3
  24. package/dist/types/tools/index.d.ts +1 -0
  25. package/dist/types/tools/skill-discovery.d.ts +40 -0
  26. package/package.json +7 -7
  27. package/src/cli/args.ts +8 -0
  28. package/src/cli/notify-cli.ts +27 -1
  29. package/src/cli.ts +5 -0
  30. package/src/commands/launch.ts +5 -0
  31. package/src/commands/notify.ts +2 -2
  32. package/src/config/model-profile-activation.ts +23 -7
  33. package/src/config/model-registry.ts +21 -4
  34. package/src/config/model-resolver.ts +5 -16
  35. package/src/config/settings-schema.ts +3 -2
  36. package/src/config/settings.ts +24 -1
  37. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  38. package/src/gjc-runtime/launch-tmux.ts +72 -12
  39. package/src/gjc-runtime/psmux-detect.ts +17 -2
  40. package/src/gjc-runtime/team-runtime.ts +35 -3
  41. package/src/main.ts +26 -3
  42. package/src/modes/components/settings-selector.ts +3 -3
  43. package/src/modes/components/thinking-selector.ts +79 -18
  44. package/src/modes/controllers/input-controller.ts +50 -9
  45. package/src/modes/controllers/selector-controller.ts +7 -5
  46. package/src/modes/types.ts +1 -0
  47. package/src/modes/utils/ui-helpers.ts +48 -15
  48. package/src/notifications/config-commands.ts +70 -0
  49. package/src/notifications/index.ts +156 -9
  50. package/src/notifications/rich-render.ts +1 -0
  51. package/src/notifications/telegram-daemon-cli.ts +13 -5
  52. package/src/notifications/telegram-daemon.ts +87 -4
  53. package/src/notifications/threaded-render.ts +14 -0
  54. package/src/notifications/topic-registry.ts +9 -8
  55. package/src/prompts/tools/skill-discovery.md +13 -0
  56. package/src/runtime-credential-selector.ts +35 -0
  57. package/src/sdk.ts +68 -5
  58. package/src/session/agent-session.ts +42 -18
  59. package/src/system-prompt.ts +4 -6
  60. package/src/tools/index.ts +4 -0
  61. package/src/tools/skill-discovery.ts +73 -0
  62. package/src/tools/skill.ts +15 -2
@@ -31,6 +31,9 @@ export interface NotifyCommandDeps {
31
31
  setupRedact?: boolean;
32
32
  setupInteractive?: boolean;
33
33
  threadedModePrompt?: (message: string) => Promise<string>;
34
+ tokenPrompt?: () => Promise<string>;
35
+ setExitCode?: (code: number) => void;
36
+ exitProcess?: (code: number) => void;
34
37
  }
35
38
 
36
39
  interface TelegramApiResponse<T> {
@@ -126,6 +129,27 @@ export async function runNotifyCommand(cmd: NotifyCommandArgs, deps: NotifyComma
126
129
  }
127
130
  }
128
131
 
132
+ export async function runNotifyCliCommand(cmd: NotifyCommandArgs, deps: NotifyCommandDeps = {}): Promise<void> {
133
+ try {
134
+ await runNotifyCommand(cmd, deps);
135
+ } catch (error) {
136
+ if (cmd.action !== "setup" || !(error instanceof Error)) {
137
+ throw error;
138
+ }
139
+
140
+ const cancelled = error.message === "Telegram bot token prompt cancelled.";
141
+ process.stderr.write(cancelled ? "Telegram notify setup cancelled.\n" : `Error: ${error.message}\n`);
142
+ const code = cancelled ? 130 : 1;
143
+ if (deps.setExitCode) {
144
+ deps.setExitCode(code);
145
+ } else {
146
+ process.exitCode = code;
147
+ }
148
+ const exitProcess = deps.exitProcess ?? (deps.setExitCode ? undefined : process.exit);
149
+ exitProcess?.(code);
150
+ }
151
+ }
152
+
129
153
  async function getSettings(deps: NotifyCommandDeps): Promise<Settings> {
130
154
  if (deps.settings) return deps.settings;
131
155
  const { Settings } = await import("../config/settings");
@@ -136,7 +160,7 @@ async function runSetup(deps: NotifyCommandDeps): Promise<void> {
136
160
  const settings = await getSettings(deps);
137
161
  const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
138
162
  const apiBase = deps.apiBase ?? DEFAULT_API_BASE;
139
- const token = deps.setupToken ?? (await promptForToken());
163
+ const token = deps.setupToken ?? (await (deps.tokenPrompt ?? promptForToken)());
140
164
  if (!token.trim()) {
141
165
  throw new Error("Telegram bot token is required.");
142
166
  }
@@ -179,6 +203,7 @@ async function runSetup(deps: NotifyCommandDeps): Promise<void> {
179
203
  type TokenPromptInput = NodeJS.ReadStream & {
180
204
  isRaw?: boolean;
181
205
  setRawMode?: (mode: boolean) => unknown;
206
+ pause?: () => unknown;
182
207
  };
183
208
 
184
209
  type TokenPromptOutput = Pick<NodeJS.WriteStream, "write">;
@@ -206,6 +231,7 @@ export async function promptForToken(
206
231
  input.off("data", onData);
207
232
  input.off("error", onError);
208
233
  input.setRawMode?.(wasRaw);
234
+ input.pause?.();
209
235
  output.write("\n");
210
236
  };
211
237
 
package/src/cli.ts CHANGED
@@ -146,6 +146,10 @@ export class RootHelpCommand extends Command {
146
146
  default: Flags.boolean({ description: "Persist --mpreset as the default model profile" }),
147
147
  provider: Flags.string({ description: "Provider to use (legacy; prefer --model)" }),
148
148
  "api-key": Flags.string({ description: "API key (defaults to env vars)" }),
149
+ credential: Flags.string({
150
+ description:
151
+ "Stored credential selector: email:<addr>, id:<n>, account:<id>, project:<id>, or provider/email:<addr>",
152
+ }),
149
153
  "system-prompt": Flags.string({ description: "System prompt (default: coding assistant prompt)" }),
150
154
  "append-system-prompt": Flags.string({ description: "Append text or file contents to the system prompt" }),
151
155
  "allow-home": Flags.boolean({ description: "Allow starting in ~ without auto-switching to a temp dir" }),
@@ -191,6 +195,7 @@ export class RootHelpCommand extends Command {
191
195
  `# Launch in a sibling git worktree\n ${APP_NAME} --worktree`,
192
196
  `# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`,
193
197
  `# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`,
198
+ `# Pin a stored credential for this session\n ${APP_NAME} --credential email:me@example.com`,
194
199
  `# Activate a model profile for this session\n ${APP_NAME} --mpreset codex-medium`,
195
200
  `# Persist a model profile as the default\n ${APP_NAME} --mpreset opencodego --default`,
196
201
  `# Export a session file to HTML\n ${APP_NAME} --export ~/.gjc/agent/sessions/--path--/session.jsonl`,
@@ -87,6 +87,10 @@ export default class Index extends Command {
87
87
  "api-key": Flags.string({
88
88
  description: "API key (defaults to env vars)",
89
89
  }),
90
+ credential: Flags.string({
91
+ description:
92
+ "Stored credential selector: email:<addr>, id:<n>, account:<id>, project:<id>, or provider/email:<addr>",
93
+ }),
90
94
  "system-prompt": Flags.string({
91
95
  description: "System prompt (default: coding assistant prompt)",
92
96
  }),
@@ -181,6 +185,7 @@ export default class Index extends Command {
181
185
  `# Launch in a sibling git worktree\n ${APP_NAME} --worktree`,
182
186
  `# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`,
183
187
  `# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`,
188
+ `# Pin a stored credential for this session\n ${APP_NAME} --credential email:me@example.com`,
184
189
  `# Activate a model profile for this session\n ${APP_NAME} --mpreset codex-medium`,
185
190
  `# Persist a model profile as the default\n ${APP_NAME} --mpreset opencodego --default`,
186
191
  `# Export a session file to HTML\n ${APP_NAME} --export ~/.gjc/agent/sessions/--path--/session.jsonl`,
@@ -2,7 +2,7 @@
2
2
  * Configure Telegram notifications.
3
3
  */
4
4
  import { Args, Command, Flags } from "@gajae-code/utils/cli";
5
- import { type NotifyAction, type NotifyCommandArgs, runNotifyCommand } from "../cli/notify-cli";
5
+ import { type NotifyAction, type NotifyCommandArgs, runNotifyCliCommand } from "../cli/notify-cli";
6
6
  import { initTheme } from "../modes/theme/theme";
7
7
 
8
8
  const ACTIONS: NotifyAction[] = ["setup", "status", "daemon-internal"];
@@ -56,6 +56,6 @@ export default class Notify extends Command {
56
56
  };
57
57
 
58
58
  if (action !== "daemon-internal") await initTheme();
59
- await runNotifyCommand(cmd);
59
+ await runNotifyCliCommand(cmd);
60
60
  }
61
61
  }
@@ -1,4 +1,4 @@
1
- import type { ThinkingLevel } from "@gajae-code/agent-core";
1
+ import { ThinkingLevel } from "@gajae-code/agent-core";
2
2
  import type { Api, Model } from "@gajae-code/ai";
3
3
  import type { AgentSession } from "../session/agent-session";
4
4
  import { formatClampedModelSelector } from "../thinking";
@@ -43,7 +43,10 @@ export interface PrepareModelProfileActivationOptions {
43
43
  settings: Pick<Settings, "get">;
44
44
  profileName: string;
45
45
  }
46
-
46
+ export interface ApplyModelProfileActivationOptions {
47
+ persistDefault?: boolean;
48
+ thinkingLevelOverride?: ThinkingLevel;
49
+ }
47
50
  export interface PreparedModelProfileActivation {
48
51
  profileName: string;
49
52
  session: ModelProfileActivationSession & { setModelTemporary: AgentSession["setModelTemporary"] };
@@ -345,25 +348,31 @@ export async function prepareModelProfileActivation(
345
348
 
346
349
  export async function applyPreparedModelProfileActivation(
347
350
  prepared: PreparedModelProfileActivation,
348
- options: { persistDefault?: boolean } = {},
351
+ options: ApplyModelProfileActivationOptions = {},
349
352
  ): Promise<void> {
350
353
  const previousModel = prepared.previousModel;
351
354
  const previousThinkingLevel = prepared.previousThinkingLevel;
352
355
  const previousAgentModelOverrides = prepared.previousAgentModelOverrides;
353
356
  const previousModelRoles = prepared.previousModelRoles;
354
357
  const previousPersistedDefault = prepared.settings.get("modelProfile.default");
358
+ const previousDefaultThinkingLevel = prepared.settings.get("defaultThinkingLevel");
355
359
  const previousActiveModelProfile = prepared.previousActiveModelProfile;
356
360
  const previousSessionDefaultModel = prepared.previousSessionDefaultModel;
357
361
  let modelChanged = false;
358
362
  let overridesChanged = false;
359
363
  let defaultChanged = false;
360
364
  let modelRolesChanged = false;
365
+ let defaultThinkingChanged = false;
361
366
 
362
367
  try {
363
368
  if (prepared.defaultModel) {
364
- await prepared.session.setModelTemporary(prepared.defaultModel, prepared.defaultThinkingLevel, {
365
- persistAsSessionDefault: true,
366
- });
369
+ await prepared.session.setModelTemporary(
370
+ prepared.defaultModel,
371
+ options.thinkingLevelOverride ?? prepared.defaultThinkingLevel,
372
+ {
373
+ persistAsSessionDefault: true,
374
+ },
375
+ );
367
376
  modelChanged = true;
368
377
  }
369
378
  if (Object.keys(prepared.modelRoles).length > 0) {
@@ -380,6 +389,10 @@ export async function applyPreparedModelProfileActivation(
380
389
  if (options.persistDefault) {
381
390
  prepared.settings.set("modelRoles", {});
382
391
  prepared.settings.set("task.agentModelOverrides", {});
392
+ if (prepared.defaultThinkingLevel !== undefined && prepared.defaultThinkingLevel !== ThinkingLevel.Inherit) {
393
+ prepared.settings.set("defaultThinkingLevel", prepared.defaultThinkingLevel);
394
+ defaultThinkingChanged = true;
395
+ }
383
396
  prepared.settings.set("modelProfile.default", prepared.profileName);
384
397
  defaultChanged = true;
385
398
  await prepared.settings.flush();
@@ -390,6 +403,9 @@ export async function applyPreparedModelProfileActivation(
390
403
  prepared.settings.set("modelProfile.default", previousPersistedDefault);
391
404
  prepared.settings.set("modelRoles", previousModelRoles);
392
405
  prepared.settings.set("task.agentModelOverrides", previousAgentModelOverrides);
406
+ if (defaultThinkingChanged) {
407
+ prepared.settings.set("defaultThinkingLevel", previousDefaultThinkingLevel);
408
+ }
393
409
  }
394
410
  if (modelRolesChanged) {
395
411
  prepared.settings.override("modelRoles", previousModelRoles);
@@ -505,7 +521,7 @@ export async function restoreMaterializedModelProfileForDeletion(options: {
505
521
 
506
522
  export async function activateModelProfile(
507
523
  options: PrepareModelProfileActivationOptions,
508
- applyOptions: { persistDefault?: boolean } = {},
524
+ applyOptions: ApplyModelProfileActivationOptions = {},
509
525
  ): Promise<void> {
510
526
  const prepared = await prepareModelProfileActivation(options);
511
527
  await applyPreparedModelProfileActivation(prepared, applyOptions);
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import {
4
4
  type Api,
5
5
  type AssistantMessageEventStream,
6
+ type AuthCredentialSelector,
6
7
  type CacheRetention,
7
8
  type Context,
8
9
  createModelManager,
@@ -2667,21 +2668,37 @@ export class ModelRegistry {
2667
2668
  /**
2668
2669
  * Get API key for a model.
2669
2670
  */
2670
- async getApiKey(model: Model<Api>, sessionId?: string): Promise<string | undefined> {
2671
+ async getApiKey(
2672
+ model: Model<Api>,
2673
+ sessionId?: string,
2674
+ options: { credentialSelector?: AuthCredentialSelector } = {},
2675
+ ): Promise<string | undefined> {
2671
2676
  if (this.#keylessProviders.has(model.provider) && !this.authStorage.hasAuth(model.provider)) {
2672
2677
  return kNoAuth;
2673
2678
  }
2674
- return this.authStorage.getApiKey(model.provider, sessionId, { baseUrl: model.baseUrl, modelId: model.id });
2679
+ return this.authStorage.getApiKey(model.provider, sessionId, {
2680
+ baseUrl: model.baseUrl,
2681
+ modelId: model.id,
2682
+ credentialSelector: options.credentialSelector,
2683
+ });
2675
2684
  }
2676
2685
 
2677
2686
  /**
2678
2687
  * Get API key for a provider (e.g., "openai").
2679
2688
  */
2680
- async getApiKeyForProvider(provider: string, sessionId?: string, baseUrl?: string): Promise<string | undefined> {
2689
+ async getApiKeyForProvider(
2690
+ provider: string,
2691
+ sessionId?: string,
2692
+ baseUrl?: string,
2693
+ options: { credentialSelector?: AuthCredentialSelector } = {},
2694
+ ): Promise<string | undefined> {
2681
2695
  if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) {
2682
2696
  return kNoAuth;
2683
2697
  }
2684
- return this.authStorage.getApiKey(provider, sessionId, { baseUrl });
2698
+ return this.authStorage.getApiKey(provider, sessionId, {
2699
+ baseUrl,
2700
+ credentialSelector: options.credentialSelector,
2701
+ });
2685
2702
  }
2686
2703
 
2687
2704
  async #peekApiKeyForProvider(provider: string): Promise<string | undefined> {
@@ -3,15 +3,7 @@
3
3
  */
4
4
 
5
5
  import { ThinkingLevel } from "@gajae-code/agent-core";
6
- import {
7
- type Api,
8
- clampThinkingLevelForModel,
9
- DEFAULT_MODEL_PER_PROVIDER,
10
- type Effort,
11
- type KnownProvider,
12
- type Model,
13
- modelsAreEqual,
14
- } from "@gajae-code/ai";
6
+ import { type Api, DEFAULT_MODEL_PER_PROVIDER, type KnownProvider, type Model, modelsAreEqual } from "@gajae-code/ai";
15
7
  import { fuzzyMatch } from "@gajae-code/tui";
16
8
  import { logger } from "@gajae-code/utils";
17
9
  import chalk from "chalk";
@@ -1197,7 +1189,7 @@ export async function findInitialModel(options: {
1197
1189
  isContinuing: boolean;
1198
1190
  defaultProvider?: string;
1199
1191
  defaultModelId?: string;
1200
- defaultThinkingSelector?: Effort;
1192
+ defaultThinkingSelector?: ThinkingLevel;
1201
1193
  modelRegistry: InitialModelRegistry;
1202
1194
  }): Promise<InitialModelResult> {
1203
1195
  const {
@@ -1212,7 +1204,7 @@ export async function findInitialModel(options: {
1212
1204
  } = options;
1213
1205
 
1214
1206
  let model: Model<Api> | undefined;
1215
- let thinkingLevel: Effort | undefined;
1207
+ let thinkingLevel: ThinkingLevel | undefined;
1216
1208
 
1217
1209
  // 1. CLI args take priority
1218
1210
  if (cliProvider && cliModel) {
@@ -1233,10 +1225,7 @@ export async function findInitialModel(options: {
1233
1225
  : (scoped.thinkingLevel ?? defaultThinkingSelector);
1234
1226
  return {
1235
1227
  model: scoped.model,
1236
- thinkingLevel:
1237
- scopedThinkingSelector === ThinkingLevel.Off
1238
- ? ThinkingLevel.Off
1239
- : clampThinkingLevelForModel(scoped.model, scopedThinkingSelector),
1228
+ thinkingLevel: resolveThinkingLevelForModel(scoped.model, scopedThinkingSelector),
1240
1229
  fallbackMessage: undefined,
1241
1230
  };
1242
1231
  }
@@ -1246,7 +1235,7 @@ export async function findInitialModel(options: {
1246
1235
  const found = modelRegistry.find(defaultProvider, defaultModelId);
1247
1236
  if (found) {
1248
1237
  model = found;
1249
- thinkingLevel = clampThinkingLevelForModel(found, defaultThinkingSelector);
1238
+ thinkingLevel = resolveThinkingLevelForModel(found, defaultThinkingSelector);
1250
1239
  return { model, thinkingLevel, fallbackMessage: undefined };
1251
1240
  }
1252
1241
  }
@@ -5,6 +5,7 @@ import { EDIT_MODES } from "../utils/edit-mode";
5
5
  import { CONFIGURABLE_SEARCH_PROVIDER_IDS } from "../web/search/types";
6
6
 
7
7
  const THINKING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as readonly Effort[];
8
+ const DEFAULT_THINKING_LEVELS = ["off", ...THINKING_EFFORTS] as const;
8
9
 
9
10
  import {
10
11
  DEFAULT_DISABLED_EXTENSIONS,
@@ -804,13 +805,13 @@ export const SETTINGS_SCHEMA = {
804
805
  // Reasoning and prompts
805
806
  defaultThinkingLevel: {
806
807
  type: "enum",
807
- values: THINKING_EFFORTS,
808
+ values: DEFAULT_THINKING_LEVELS,
808
809
  default: THINKING_EFFORTS[3],
809
810
  ui: {
810
811
  tab: "model",
811
812
  label: "Thinking Level",
812
813
  description: "Reasoning depth for thinking-capable models",
813
- options: [...THINKING_EFFORTS.map(getThinkingLevelMetadata)],
814
+ options: [...DEFAULT_THINKING_LEVELS.map(getThinkingLevelMetadata)],
814
815
  },
815
816
  },
816
817
 
@@ -374,6 +374,25 @@ export class Settings {
374
374
  }
375
375
  }
376
376
 
377
+ /**
378
+ * Like {@link flush}, but rejects if the durable save fails instead of
379
+ * swallowing the error. Use where the caller must confirm persistence before
380
+ * reporting success (e.g. the Telegram `/rich` toggle). In-memory instances
381
+ * ({@link isolated}) short-circuit in {@link #saveNow} and never throw.
382
+ */
383
+ async flushOrThrow(): Promise<void> {
384
+ if (this.#saveTimer) {
385
+ clearTimeout(this.#saveTimer);
386
+ this.#saveTimer = undefined;
387
+ }
388
+ if (this.#savePromise) {
389
+ await this.#savePromise;
390
+ }
391
+ if (this.#modified.size > 0) {
392
+ await this.#saveNow({ throwOnError: true });
393
+ }
394
+ }
395
+
377
396
  async cloneForCwd(cwd: string): Promise<Settings> {
378
397
  const cloned = new Settings({
379
398
  cwd,
@@ -817,7 +836,7 @@ export class Settings {
817
836
  }, 100);
818
837
  }
819
838
 
820
- async #saveNow(): Promise<void> {
839
+ async #saveNow(options: { throwOnError?: boolean } = {}): Promise<void> {
821
840
  if (!this.#persist || !this.#configPath || this.#modified.size === 0) return;
822
841
 
823
842
  const configPath = this.#configPath;
@@ -846,6 +865,10 @@ export class Settings {
846
865
  for (const p of modifiedPaths) {
847
866
  this.#modified.add(p);
848
867
  }
868
+ if (options.throwOnError) {
869
+ this.#rebuildMerged();
870
+ throw error;
871
+ }
849
872
  }
850
873
 
851
874
  this.#rebuildMerged();
@@ -0,0 +1,229 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { findRepoRoot } from "../capability/fs";
5
+ import type { Skill as CapabilitySkill } from "../capability/skill";
6
+ import type { SkillsSettings } from "../config/settings-schema";
7
+ import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers";
8
+ import type { Skill } from "./skills";
9
+
10
+ export type RuntimeSkillDiscoverySource = "project" | "user";
11
+
12
+ export interface RuntimeSkillDiscoveryCandidate {
13
+ name: string;
14
+ description: string;
15
+ source: RuntimeSkillDiscoverySource;
16
+ path: string;
17
+ useWhen?: string[];
18
+ }
19
+
20
+ export interface DiscoverRuntimeSkillsOptions {
21
+ cwd: string;
22
+ home?: string;
23
+ query?: string;
24
+ limit?: number;
25
+ source?: RuntimeSkillDiscoverySource | "all";
26
+ policy?: SkillsSettings;
27
+ }
28
+
29
+ function getRuntimeHome(): string {
30
+ return process.env.HOME || os.homedir();
31
+ }
32
+
33
+ const DEFAULT_LIMIT = 20;
34
+ const MAX_LIMIT = 50;
35
+
36
+ function normalizeLimit(limit: number | undefined): number {
37
+ if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_LIMIT;
38
+ return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
39
+ }
40
+
41
+ async function getProjectSkillDirs(cwd: string, home: string): Promise<{ dirs: string[]; repoRoot: string | null }> {
42
+ const dirs: string[] = [];
43
+ let current = path.resolve(cwd);
44
+ const resolvedHome = path.resolve(home);
45
+ const repoRoot = await findRepoRoot(current);
46
+ const stop = path.resolve(repoRoot ?? current);
47
+ while (true) {
48
+ if (current !== resolvedHome) {
49
+ dirs.push(path.join(current, ".gjc", "skills"));
50
+ }
51
+ if (current === stop) break;
52
+ const parent = path.dirname(current);
53
+ if (parent === current) break;
54
+ current = parent;
55
+ }
56
+ return { dirs, repoRoot };
57
+ }
58
+
59
+ function getUseWhen(skill: CapabilitySkill): string[] | undefined {
60
+ const frontmatter = skill.frontmatter as Record<string, unknown> | undefined;
61
+ const values: string[] = [];
62
+ const globs = frontmatter?.globs;
63
+ if (Array.isArray(globs)) {
64
+ values.push(...globs.filter((value): value is string => typeof value === "string"));
65
+ } else if (typeof globs === "string") {
66
+ values.push(globs);
67
+ }
68
+ for (const key of ["use_when", "useWhen", "conditions"]) {
69
+ const raw = frontmatter?.[key];
70
+ if (typeof raw === "string") values.push(raw);
71
+ if (Array.isArray(raw)) values.push(...raw.filter((value): value is string => typeof value === "string"));
72
+ }
73
+ return values.length > 0 ? values : undefined;
74
+ }
75
+
76
+ function toRuntimeSkill(skill: CapabilitySkill, source: RuntimeSkillDiscoverySource): Skill {
77
+ return {
78
+ name: skill.name,
79
+ description: typeof skill.frontmatter?.description === "string" ? skill.frontmatter.description : "",
80
+ filePath: skill.path,
81
+ baseDir: skill.path.replace(/[\\/]SKILL\.md$/, ""),
82
+ source: `runtime:${source}`,
83
+ hide: skill.frontmatter?.hide === true,
84
+ _source: { ...skill._source, providerName: "Runtime skill discovery" },
85
+ };
86
+ }
87
+ function sourceEnabled(source: RuntimeSkillDiscoverySource, policy: SkillsSettings | undefined): boolean {
88
+ if (policy?.enabled !== true) return false;
89
+ if (source === "project") return policy.enablePiProject === true;
90
+ if (source === "user") return policy.enablePiUser === true;
91
+ return false;
92
+ }
93
+
94
+ function matchesIncludePatterns(name: string, includeSkills: string[] | undefined): boolean {
95
+ if (!includeSkills || includeSkills.length === 0) return true;
96
+ return includeSkills.some(pattern => new Bun.Glob(pattern).match(name));
97
+ }
98
+
99
+ function matchesIgnorePatterns(name: string, ignoredSkills: string[] | undefined): boolean {
100
+ if (!ignoredSkills || ignoredSkills.length === 0) return false;
101
+ return ignoredSkills.some(pattern => new Bun.Glob(pattern).match(name));
102
+ }
103
+
104
+ function isDisabledSkill(name: string, disabledExtensions: string[] | undefined): boolean {
105
+ return (disabledExtensions ?? []).some(id => id === `skill:${name}`);
106
+ }
107
+
108
+ function isAllowedByPolicy(
109
+ skill: CapabilitySkill,
110
+ source: RuntimeSkillDiscoverySource,
111
+ policy: SkillsSettings | undefined,
112
+ ): boolean {
113
+ if (!sourceEnabled(source, policy)) return false;
114
+ if (isDisabledSkill(skill.name, policy?.disabledExtensions)) return false;
115
+ if (matchesIgnorePatterns(skill.name, policy?.ignoredSkills)) return false;
116
+ if (!matchesIncludePatterns(skill.name, policy?.includeSkills)) return false;
117
+ return true;
118
+ }
119
+ function matchesQuery(candidate: RuntimeSkillDiscoveryCandidate, query: string): boolean {
120
+ const normalized = query.trim().toLowerCase();
121
+ if (!normalized) return true;
122
+ const haystack = [candidate.name, candidate.description, candidate.source, ...(candidate.useWhen ?? [])]
123
+ .join("\n")
124
+ .toLowerCase();
125
+ return normalized
126
+ .split(/\s+/)
127
+ .filter(Boolean)
128
+ .every(term => haystack.includes(term));
129
+ }
130
+
131
+ async function realPathOrSelf(filePath: string): Promise<string> {
132
+ try {
133
+ return await fs.realpath(filePath);
134
+ } catch {
135
+ return filePath;
136
+ }
137
+ }
138
+
139
+ export async function discoverRuntimeSkills(
140
+ options: DiscoverRuntimeSkillsOptions,
141
+ ): Promise<RuntimeSkillDiscoveryCandidate[]> {
142
+ const home = options.home ?? getRuntimeHome();
143
+ const source = options.source ?? "all";
144
+ const policy = options.policy;
145
+ const scanJobs: Array<Promise<{ skill: CapabilitySkill; source: RuntimeSkillDiscoverySource }[]>> = [];
146
+ const projectSkills = await getProjectSkillDirs(options.cwd, home);
147
+ const projectContext = { cwd: options.cwd, home, repoRoot: projectSkills.repoRoot };
148
+ if ((source === "all" || source === "project") && sourceEnabled("project", policy)) {
149
+ for (const dir of projectSkills.dirs) {
150
+ scanJobs.push(
151
+ scanSkillsFromDir(projectContext, {
152
+ dir,
153
+ providerId: "runtime",
154
+ level: "project",
155
+ requireDescription: true,
156
+ }).then(result => result.items.map(skill => ({ skill, source: "project" as const }))),
157
+ );
158
+ }
159
+ }
160
+ if ((source === "all" || source === "user") && sourceEnabled("user", policy)) {
161
+ scanJobs.push(
162
+ scanSkillsFromDir(
163
+ { cwd: options.cwd, home, repoRoot: home },
164
+ { dir: path.join(home, ".gjc", "skills"), providerId: "runtime", level: "user", requireDescription: true },
165
+ ).then(result => result.items.map(skill => ({ skill, source: "user" as const }))),
166
+ );
167
+ }
168
+
169
+ const seenNames = new Set<string>();
170
+ const seenPaths = new Set<string>();
171
+ const candidates: RuntimeSkillDiscoveryCandidate[] = [];
172
+ for (const entry of (await Promise.all(scanJobs)).flat()) {
173
+ if (!isAllowedByPolicy(entry.skill, entry.source, policy)) continue;
174
+ const realPath = await realPathOrSelf(entry.skill.path);
175
+ if (seenPaths.has(realPath) || seenNames.has(entry.skill.name)) continue;
176
+ seenPaths.add(realPath);
177
+ seenNames.add(entry.skill.name);
178
+ const candidate: RuntimeSkillDiscoveryCandidate = {
179
+ name: entry.skill.name,
180
+ description:
181
+ typeof entry.skill.frontmatter?.description === "string" ? entry.skill.frontmatter.description : "",
182
+ source: entry.source,
183
+ path: entry.skill.path,
184
+ useWhen: getUseWhen(entry.skill),
185
+ };
186
+ if (matchesQuery(candidate, options.query ?? "")) candidates.push(candidate);
187
+ }
188
+ candidates.sort((a, b) => compareSkillOrder(a.name, a.path, b.name, b.path));
189
+ return candidates.slice(0, normalizeLimit(options.limit));
190
+ }
191
+
192
+ export async function findRuntimeSkillByName(
193
+ cwd: string,
194
+ name: string,
195
+ policy?: SkillsSettings,
196
+ home = getRuntimeHome(),
197
+ ): Promise<Skill | undefined> {
198
+ const normalized = name.trim();
199
+ if (!normalized) return undefined;
200
+ const scanJobs: Array<Promise<{ skill: CapabilitySkill; source: RuntimeSkillDiscoverySource }[]>> = [];
201
+ const projectSkills = await getProjectSkillDirs(cwd, home);
202
+ const projectContext = { cwd, home, repoRoot: projectSkills.repoRoot };
203
+ if (sourceEnabled("project", policy)) {
204
+ scanJobs.push(
205
+ ...projectSkills.dirs.map(dir =>
206
+ scanSkillsFromDir(projectContext, {
207
+ dir,
208
+ providerId: "runtime",
209
+ level: "project",
210
+ requireDescription: true,
211
+ }).then(result => result.items.map(skill => ({ skill, source: "project" as const }))),
212
+ ),
213
+ );
214
+ }
215
+ if (sourceEnabled("user", policy)) {
216
+ scanJobs.push(
217
+ scanSkillsFromDir(
218
+ { cwd, home, repoRoot: home },
219
+ { dir: path.join(home, ".gjc", "skills"), providerId: "runtime", level: "user", requireDescription: true },
220
+ ).then(result => result.items.map(skill => ({ skill, source: "user" as const }))),
221
+ );
222
+ }
223
+ for (const entry of (await Promise.all(scanJobs)).flat()) {
224
+ if (entry.skill.name === normalized && isAllowedByPolicy(entry.skill, entry.source, policy)) {
225
+ return toRuntimeSkill(entry.skill, entry.source);
226
+ }
227
+ }
228
+ return undefined;
229
+ }