@gmickel/gno 0.3.0

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 (131) hide show
  1. package/README.md +256 -0
  2. package/assets/skill/SKILL.md +112 -0
  3. package/assets/skill/cli-reference.md +327 -0
  4. package/assets/skill/examples.md +234 -0
  5. package/assets/skill/mcp-reference.md +159 -0
  6. package/package.json +90 -0
  7. package/src/app/constants.ts +313 -0
  8. package/src/cli/colors.ts +65 -0
  9. package/src/cli/commands/ask.ts +545 -0
  10. package/src/cli/commands/cleanup.ts +105 -0
  11. package/src/cli/commands/collection/add.ts +120 -0
  12. package/src/cli/commands/collection/index.ts +10 -0
  13. package/src/cli/commands/collection/list.ts +108 -0
  14. package/src/cli/commands/collection/remove.ts +64 -0
  15. package/src/cli/commands/collection/rename.ts +95 -0
  16. package/src/cli/commands/context/add.ts +67 -0
  17. package/src/cli/commands/context/check.ts +153 -0
  18. package/src/cli/commands/context/index.ts +10 -0
  19. package/src/cli/commands/context/list.ts +109 -0
  20. package/src/cli/commands/context/rm.ts +52 -0
  21. package/src/cli/commands/doctor.ts +393 -0
  22. package/src/cli/commands/embed.ts +462 -0
  23. package/src/cli/commands/get.ts +356 -0
  24. package/src/cli/commands/index-cmd.ts +119 -0
  25. package/src/cli/commands/index.ts +102 -0
  26. package/src/cli/commands/init.ts +328 -0
  27. package/src/cli/commands/ls.ts +217 -0
  28. package/src/cli/commands/mcp/config.ts +300 -0
  29. package/src/cli/commands/mcp/index.ts +24 -0
  30. package/src/cli/commands/mcp/install.ts +203 -0
  31. package/src/cli/commands/mcp/paths.ts +470 -0
  32. package/src/cli/commands/mcp/status.ts +222 -0
  33. package/src/cli/commands/mcp/uninstall.ts +158 -0
  34. package/src/cli/commands/mcp.ts +20 -0
  35. package/src/cli/commands/models/clear.ts +103 -0
  36. package/src/cli/commands/models/index.ts +32 -0
  37. package/src/cli/commands/models/list.ts +214 -0
  38. package/src/cli/commands/models/path.ts +51 -0
  39. package/src/cli/commands/models/pull.ts +199 -0
  40. package/src/cli/commands/models/use.ts +85 -0
  41. package/src/cli/commands/multi-get.ts +400 -0
  42. package/src/cli/commands/query.ts +220 -0
  43. package/src/cli/commands/ref-parser.ts +108 -0
  44. package/src/cli/commands/reset.ts +191 -0
  45. package/src/cli/commands/search.ts +136 -0
  46. package/src/cli/commands/shared.ts +156 -0
  47. package/src/cli/commands/skill/index.ts +19 -0
  48. package/src/cli/commands/skill/install.ts +197 -0
  49. package/src/cli/commands/skill/paths-cmd.ts +81 -0
  50. package/src/cli/commands/skill/paths.ts +191 -0
  51. package/src/cli/commands/skill/show.ts +73 -0
  52. package/src/cli/commands/skill/uninstall.ts +141 -0
  53. package/src/cli/commands/status.ts +205 -0
  54. package/src/cli/commands/update.ts +68 -0
  55. package/src/cli/commands/vsearch.ts +188 -0
  56. package/src/cli/context.ts +64 -0
  57. package/src/cli/errors.ts +64 -0
  58. package/src/cli/format/search-results.ts +211 -0
  59. package/src/cli/options.ts +183 -0
  60. package/src/cli/program.ts +1330 -0
  61. package/src/cli/run.ts +213 -0
  62. package/src/cli/ui.ts +92 -0
  63. package/src/config/defaults.ts +20 -0
  64. package/src/config/index.ts +55 -0
  65. package/src/config/loader.ts +161 -0
  66. package/src/config/paths.ts +87 -0
  67. package/src/config/saver.ts +153 -0
  68. package/src/config/types.ts +280 -0
  69. package/src/converters/adapters/markitdownTs/adapter.ts +140 -0
  70. package/src/converters/adapters/officeparser/adapter.ts +126 -0
  71. package/src/converters/canonicalize.ts +89 -0
  72. package/src/converters/errors.ts +218 -0
  73. package/src/converters/index.ts +51 -0
  74. package/src/converters/mime.ts +163 -0
  75. package/src/converters/native/markdown.ts +115 -0
  76. package/src/converters/native/plaintext.ts +56 -0
  77. package/src/converters/path.ts +48 -0
  78. package/src/converters/pipeline.ts +159 -0
  79. package/src/converters/registry.ts +74 -0
  80. package/src/converters/types.ts +123 -0
  81. package/src/converters/versions.ts +24 -0
  82. package/src/index.ts +27 -0
  83. package/src/ingestion/chunker.ts +238 -0
  84. package/src/ingestion/index.ts +32 -0
  85. package/src/ingestion/language.ts +276 -0
  86. package/src/ingestion/sync.ts +671 -0
  87. package/src/ingestion/types.ts +219 -0
  88. package/src/ingestion/walker.ts +235 -0
  89. package/src/llm/cache.ts +467 -0
  90. package/src/llm/errors.ts +191 -0
  91. package/src/llm/index.ts +58 -0
  92. package/src/llm/nodeLlamaCpp/adapter.ts +133 -0
  93. package/src/llm/nodeLlamaCpp/embedding.ts +165 -0
  94. package/src/llm/nodeLlamaCpp/generation.ts +88 -0
  95. package/src/llm/nodeLlamaCpp/lifecycle.ts +317 -0
  96. package/src/llm/nodeLlamaCpp/rerank.ts +94 -0
  97. package/src/llm/registry.ts +86 -0
  98. package/src/llm/types.ts +129 -0
  99. package/src/mcp/resources/index.ts +151 -0
  100. package/src/mcp/server.ts +229 -0
  101. package/src/mcp/tools/get.ts +220 -0
  102. package/src/mcp/tools/index.ts +160 -0
  103. package/src/mcp/tools/multi-get.ts +263 -0
  104. package/src/mcp/tools/query.ts +226 -0
  105. package/src/mcp/tools/search.ts +119 -0
  106. package/src/mcp/tools/status.ts +81 -0
  107. package/src/mcp/tools/vsearch.ts +198 -0
  108. package/src/pipeline/chunk-lookup.ts +44 -0
  109. package/src/pipeline/expansion.ts +256 -0
  110. package/src/pipeline/explain.ts +115 -0
  111. package/src/pipeline/fusion.ts +185 -0
  112. package/src/pipeline/hybrid.ts +535 -0
  113. package/src/pipeline/index.ts +64 -0
  114. package/src/pipeline/query-language.ts +118 -0
  115. package/src/pipeline/rerank.ts +223 -0
  116. package/src/pipeline/search.ts +261 -0
  117. package/src/pipeline/types.ts +328 -0
  118. package/src/pipeline/vsearch.ts +348 -0
  119. package/src/store/index.ts +41 -0
  120. package/src/store/migrations/001-initial.ts +196 -0
  121. package/src/store/migrations/index.ts +20 -0
  122. package/src/store/migrations/runner.ts +187 -0
  123. package/src/store/sqlite/adapter.ts +1242 -0
  124. package/src/store/sqlite/index.ts +7 -0
  125. package/src/store/sqlite/setup.ts +129 -0
  126. package/src/store/sqlite/types.ts +28 -0
  127. package/src/store/types.ts +506 -0
  128. package/src/store/vector/index.ts +13 -0
  129. package/src/store/vector/sqlite-vec.ts +373 -0
  130. package/src/store/vector/stats.ts +152 -0
  131. package/src/store/vector/types.ts +115 -0
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Shared CLI command utilities.
3
+ * Common initialization and formatting helpers.
4
+ *
5
+ * @module src/cli/commands/shared
6
+ */
7
+
8
+ import { getIndexDbPath } from '../../app/constants';
9
+ import { getConfigPaths, isInitialized, loadConfig } from '../../config';
10
+ import type { Collection, Config } from '../../config/types';
11
+ import type { SyncResult } from '../../ingestion';
12
+ import { SqliteAdapter } from '../../store/sqlite/adapter';
13
+
14
+ /**
15
+ * Result of CLI store initialization.
16
+ */
17
+ export type InitStoreResult =
18
+ | {
19
+ ok: true;
20
+ store: SqliteAdapter;
21
+ config: Config;
22
+ collections: Collection[];
23
+ /** Actual config path used (for status reporting) */
24
+ actualConfigPath: string;
25
+ }
26
+ | { ok: false; error: string };
27
+
28
+ /**
29
+ * Options for store initialization.
30
+ */
31
+ export interface InitStoreOptions {
32
+ /** Override config path */
33
+ configPath?: string;
34
+ /** Index name (defaults to 'default') */
35
+ indexName?: string;
36
+ /** Filter to single collection by name */
37
+ collection?: string;
38
+ }
39
+
40
+ /**
41
+ * Initialize store for CLI commands.
42
+ * Handles: isInitialized check, loadConfig, DB open, syncCollections, syncContexts.
43
+ *
44
+ * Caller is responsible for calling store.close() when done.
45
+ */
46
+ export async function initStore(
47
+ options: InitStoreOptions = {}
48
+ ): Promise<InitStoreResult> {
49
+ // Check if initialized
50
+ const initialized = await isInitialized(options.configPath);
51
+ if (!initialized) {
52
+ return { ok: false, error: 'GNO not initialized. Run: gno init' };
53
+ }
54
+
55
+ // Load config
56
+ const configResult = await loadConfig(options.configPath);
57
+ if (!configResult.ok) {
58
+ return { ok: false, error: configResult.error.message };
59
+ }
60
+ const config = configResult.value;
61
+
62
+ // Filter to single collection if specified
63
+ let collections = config.collections;
64
+ if (options.collection) {
65
+ collections = collections.filter((c) => c.name === options.collection);
66
+ if (collections.length === 0) {
67
+ return {
68
+ ok: false,
69
+ error: `Collection not found: ${options.collection}`,
70
+ };
71
+ }
72
+ }
73
+
74
+ if (collections.length === 0) {
75
+ return {
76
+ ok: false,
77
+ error: 'No collections configured. Run: gno collection add <path>',
78
+ };
79
+ }
80
+
81
+ // Ensure data directory exists (may have been deleted by reset)
82
+ const { ensureDirectories } = await import('../../config');
83
+ await ensureDirectories();
84
+
85
+ // Open database (honor indexName option)
86
+ const store = new SqliteAdapter();
87
+ const dbPath = getIndexDbPath(options.indexName);
88
+ const paths = getConfigPaths();
89
+
90
+ // Actual config path used (options.configPath overrides default)
91
+ const actualConfigPath = options.configPath ?? paths.configFile;
92
+
93
+ // Set configPath for status output
94
+ store.setConfigPath(actualConfigPath);
95
+
96
+ const openResult = await store.open(dbPath, config.ftsTokenizer);
97
+ if (!openResult.ok) {
98
+ return { ok: false, error: openResult.error.message };
99
+ }
100
+
101
+ // Sync collections from config to DB
102
+ const syncCollResult = await store.syncCollections(config.collections);
103
+ if (!syncCollResult.ok) {
104
+ await store.close();
105
+ return { ok: false, error: syncCollResult.error.message };
106
+ }
107
+
108
+ // Sync contexts from config to DB
109
+ const syncCtxResult = await store.syncContexts(config.contexts ?? []);
110
+ if (!syncCtxResult.ok) {
111
+ await store.close();
112
+ return { ok: false, error: syncCtxResult.error.message };
113
+ }
114
+
115
+ return { ok: true, store, config, collections, actualConfigPath };
116
+ }
117
+
118
+ /**
119
+ * Format sync result lines (shared between update and index commands).
120
+ */
121
+ export function formatSyncResultLines(
122
+ syncResult: SyncResult,
123
+ options: { verbose?: boolean }
124
+ ): string[] {
125
+ const lines: string[] = [];
126
+
127
+ for (const c of syncResult.collections) {
128
+ lines.push(`${c.collection}:`);
129
+ lines.push(
130
+ ` ${c.filesAdded} added, ${c.filesUpdated} updated, ${c.filesUnchanged} unchanged`
131
+ );
132
+ if (c.filesErrored > 0) {
133
+ lines.push(` ${c.filesErrored} errors`);
134
+ }
135
+ if (c.filesMarkedInactive > 0) {
136
+ lines.push(` ${c.filesMarkedInactive} marked inactive`);
137
+ }
138
+
139
+ if (options.verbose && c.errors.length > 0) {
140
+ for (const err of c.errors) {
141
+ lines.push(` [${err.code}] ${err.relPath}: ${err.message}`);
142
+ }
143
+ }
144
+ }
145
+
146
+ lines.push('');
147
+ lines.push(
148
+ `Total: ${syncResult.totalFilesAdded} added, ${syncResult.totalFilesUpdated} updated` +
149
+ (syncResult.totalFilesErrored > 0
150
+ ? `, ${syncResult.totalFilesErrored} errors`
151
+ : '')
152
+ );
153
+ lines.push(`Duration: ${syncResult.totalDurationMs}ms`);
154
+
155
+ return lines;
156
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Skill command exports.
3
+ *
4
+ * @module src/cli/commands/skill
5
+ */
6
+
7
+ export { type InstallOptions, installSkill } from './install.js';
8
+ export {
9
+ resolveAllPaths,
10
+ resolveSkillPaths,
11
+ type SkillPathOptions,
12
+ type SkillPaths,
13
+ type SkillScope,
14
+ type SkillTarget,
15
+ validatePathForDeletion,
16
+ } from './paths.js';
17
+ export { type PathsOptions, showPaths } from './paths-cmd.js';
18
+ export { type ShowOptions, showSkill } from './show.js';
19
+ export { type UninstallOptions, uninstallSkill } from './uninstall.js';
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Install GNO agent skill to Claude Code or Codex.
3
+ * Atomic install via temp directory + rename.
4
+ *
5
+ * @module src/cli/commands/skill/install
6
+ */
7
+
8
+ import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
9
+ import { dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { CliError } from '../../errors.js';
12
+ import { getGlobals } from '../../program.js';
13
+ import {
14
+ resolveSkillPaths,
15
+ type SkillScope,
16
+ type SkillTarget,
17
+ validatePathForDeletion,
18
+ } from './paths.js';
19
+
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+ // Source Path Resolution
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Get path to skill source files.
26
+ * Works in both dev (src/) and after build (dist/).
27
+ */
28
+ function getSkillSourceDir(): string {
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
30
+ // From src/cli/commands/skill/ -> assets/skill/
31
+ // Or from dist/cli/commands/skill/ -> assets/skill/
32
+ return join(__dirname, '../../../../assets/skill');
33
+ }
34
+
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // Install Command
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ export interface InstallOptions {
40
+ scope?: SkillScope;
41
+ target?: SkillTarget | 'all';
42
+ force?: boolean;
43
+ /** Override for testing */
44
+ cwd?: string;
45
+ /** Override for testing */
46
+ homeDir?: string;
47
+ /** JSON output (defaults to globals.json) */
48
+ json?: boolean;
49
+ /** Non-interactive mode (defaults to globals.yes) */
50
+ yes?: boolean;
51
+ /** Quiet mode (defaults to globals.quiet) */
52
+ quiet?: boolean;
53
+ }
54
+
55
+ interface InstallResult {
56
+ target: SkillTarget;
57
+ scope: SkillScope;
58
+ path: string;
59
+ }
60
+
61
+ /**
62
+ * Install skill to a single target.
63
+ */
64
+ async function installToTarget(
65
+ scope: SkillScope,
66
+ target: SkillTarget,
67
+ force: boolean,
68
+ overrides?: { cwd?: string; homeDir?: string }
69
+ ): Promise<InstallResult> {
70
+ const sourceDir = getSkillSourceDir();
71
+ const paths = resolveSkillPaths({ scope, target, ...overrides });
72
+
73
+ // Check if already exists (directory or SKILL.md)
74
+ const skillMdExists = await Bun.file(join(paths.gnoDir, 'SKILL.md')).exists();
75
+ let dirExists = false;
76
+ try {
77
+ const dirStat = await stat(paths.gnoDir);
78
+ dirExists = dirStat.isDirectory();
79
+ } catch {
80
+ // Directory doesn't exist
81
+ }
82
+ const destExists = skillMdExists || dirExists;
83
+
84
+ if (destExists && !force) {
85
+ throw new CliError(
86
+ 'VALIDATION',
87
+ `Skill already installed at ${paths.gnoDir}. Use --force to overwrite.`
88
+ );
89
+ }
90
+
91
+ // Read source files
92
+ const sourceFiles = await readdir(sourceDir);
93
+ if (sourceFiles.length === 0) {
94
+ throw new CliError('RUNTIME', `No skill files found in ${sourceDir}`);
95
+ }
96
+
97
+ // Create temp directory with unique name to avoid collisions
98
+ const randomSuffix = Math.random().toString(36).slice(2, 10);
99
+ const tmpName = `.gno-skill.tmp.${Date.now()}-${process.pid}-${randomSuffix}`;
100
+ const tmpDir = join(paths.skillsDir, tmpName);
101
+
102
+ try {
103
+ // Ensure skills directory exists
104
+ await mkdir(paths.skillsDir, { recursive: true });
105
+
106
+ // Create temp directory
107
+ await mkdir(tmpDir, { recursive: true });
108
+
109
+ // Copy all files to temp (binary-safe)
110
+ for (const file of sourceFiles) {
111
+ const content = await Bun.file(join(sourceDir, file)).arrayBuffer();
112
+ await Bun.write(join(tmpDir, file), content);
113
+ }
114
+
115
+ // Remove existing if present (with safety check)
116
+ if (destExists) {
117
+ const validationError = validatePathForDeletion(paths.gnoDir, paths.base);
118
+ if (validationError) {
119
+ throw new CliError(
120
+ 'RUNTIME',
121
+ `Safety check failed for ${paths.gnoDir}: ${validationError}`
122
+ );
123
+ }
124
+ await rm(paths.gnoDir, { recursive: true, force: true });
125
+ }
126
+
127
+ // Atomic rename
128
+ await rename(tmpDir, paths.gnoDir);
129
+
130
+ return { target, scope, path: paths.gnoDir };
131
+ } catch (err) {
132
+ // Best-effort cleanup
133
+ try {
134
+ await rm(tmpDir, { recursive: true, force: true });
135
+ } catch {
136
+ // Ignore cleanup errors
137
+ }
138
+
139
+ if (err instanceof CliError) {
140
+ throw err;
141
+ }
142
+
143
+ throw new CliError(
144
+ 'RUNTIME',
145
+ `Failed to install skill: ${err instanceof Error ? err.message : String(err)}`
146
+ );
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Get globals with fallback for testing.
152
+ */
153
+ function safeGetGlobals(): { json: boolean; yes: boolean; quiet: boolean } {
154
+ try {
155
+ return getGlobals();
156
+ } catch {
157
+ return { json: false, yes: false, quiet: false };
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Install GNO skill.
163
+ */
164
+ export async function installSkill(opts: InstallOptions = {}): Promise<void> {
165
+ const scope = opts.scope ?? 'project';
166
+ const target = opts.target ?? 'claude';
167
+ const force = opts.force ?? false;
168
+ const globals = safeGetGlobals();
169
+ const json = opts.json ?? globals.json;
170
+ const yes = opts.yes ?? globals.yes;
171
+ const quiet = opts.quiet ?? globals.quiet;
172
+
173
+ const targets: SkillTarget[] =
174
+ target === 'all' ? ['claude', 'codex'] : [target];
175
+
176
+ const results: InstallResult[] = [];
177
+
178
+ for (const t of targets) {
179
+ const result = await installToTarget(scope, t, force || yes, {
180
+ cwd: opts.cwd,
181
+ homeDir: opts.homeDir,
182
+ });
183
+ results.push(result);
184
+ }
185
+
186
+ // Output
187
+ if (json) {
188
+ process.stdout.write(
189
+ `${JSON.stringify({ installed: results }, null, 2)}\n`
190
+ );
191
+ } else if (!quiet) {
192
+ for (const r of results) {
193
+ process.stdout.write(`Installed GNO skill to ${r.path}\n`);
194
+ }
195
+ process.stdout.write('\nRestart your agent to load the skill.\n');
196
+ }
197
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Show resolved skill installation paths.
3
+ * Debugging helper for skill install/uninstall.
4
+ *
5
+ * @module src/cli/commands/skill/paths-cmd
6
+ */
7
+
8
+ import { join } from 'node:path';
9
+ import { getGlobals } from '../../program.js';
10
+ import { resolveAllPaths, type SkillScope, type SkillTarget } from './paths.js';
11
+
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // Paths Command
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+
16
+ export interface PathsOptions {
17
+ scope?: SkillScope | 'all';
18
+ target?: SkillTarget | 'all';
19
+ /** Override for testing */
20
+ cwd?: string;
21
+ /** Override for testing */
22
+ homeDir?: string;
23
+ /** JSON output (defaults to globals.json) */
24
+ json?: boolean;
25
+ }
26
+
27
+ interface PathInfo {
28
+ target: SkillTarget;
29
+ scope: SkillScope;
30
+ path: string;
31
+ exists: boolean;
32
+ }
33
+
34
+ /**
35
+ * Get globals with fallback for testing.
36
+ */
37
+ function safeGetGlobals(): { json: boolean } {
38
+ try {
39
+ return getGlobals();
40
+ } catch {
41
+ return { json: false };
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Show resolved skill paths.
47
+ */
48
+ export async function showPaths(opts: PathsOptions = {}): Promise<void> {
49
+ const scope = opts.scope ?? 'all';
50
+ const target = opts.target ?? 'all';
51
+ const globals = safeGetGlobals();
52
+ const json = opts.json ?? globals.json;
53
+
54
+ const resolved = resolveAllPaths(scope, target, {
55
+ cwd: opts.cwd,
56
+ homeDir: opts.homeDir,
57
+ });
58
+
59
+ const results: PathInfo[] = [];
60
+
61
+ for (const r of resolved) {
62
+ const skillMdPath = join(r.paths.gnoDir, 'SKILL.md');
63
+ const exists = await Bun.file(skillMdPath).exists();
64
+ results.push({
65
+ target: r.target,
66
+ scope: r.scope,
67
+ path: r.paths.gnoDir,
68
+ exists,
69
+ });
70
+ }
71
+
72
+ if (json) {
73
+ process.stdout.write(`${JSON.stringify({ paths: results }, null, 2)}\n`);
74
+ } else {
75
+ process.stdout.write('GNO Skill Paths:\n\n');
76
+ for (const r of results) {
77
+ const status = r.exists ? '(installed)' : '(not installed)';
78
+ process.stdout.write(` ${r.target}/${r.scope}: ${r.path} ${status}\n`);
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Path resolution for skill installation.
3
+ * Supports Claude Code and Codex targets with project/user scopes.
4
+ *
5
+ * @module src/cli/commands/skill/paths
6
+ */
7
+
8
+ import { homedir } from 'node:os';
9
+ import { isAbsolute, join, normalize, relative, sep } from 'node:path';
10
+
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ // Environment Variables
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+
15
+ /** Override home dir for user scope (testing) */
16
+ export const ENV_SKILLS_HOME_OVERRIDE = 'GNO_SKILLS_HOME_OVERRIDE';
17
+
18
+ /** Override Claude skills directory */
19
+ export const ENV_CLAUDE_SKILLS_DIR = 'CLAUDE_SKILLS_DIR';
20
+
21
+ /** Override Codex skills directory */
22
+ export const ENV_CODEX_SKILLS_DIR = 'CODEX_SKILLS_DIR';
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // Types
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ export type SkillScope = 'project' | 'user';
29
+ export type SkillTarget = 'claude' | 'codex';
30
+
31
+ export interface SkillPathOptions {
32
+ scope: SkillScope;
33
+ target: SkillTarget;
34
+ /** Override cwd for project scope (testing) */
35
+ cwd?: string;
36
+ /** Override home dir for user scope (testing) */
37
+ homeDir?: string;
38
+ }
39
+
40
+ export interface SkillPaths {
41
+ /** Base directory (e.g., ~/.claude or ./.claude) */
42
+ base: string;
43
+ /** Skills directory (e.g., ~/.claude/skills) */
44
+ skillsDir: string;
45
+ /** GNO skill directory (e.g., ~/.claude/skills/gno) */
46
+ gnoDir: string;
47
+ }
48
+
49
+ // ─────────────────────────────────────────────────────────────────────────────
50
+ // Constants
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+
53
+ /** Skill name for the gno skill directory */
54
+ export const SKILL_NAME = 'gno';
55
+
56
+ /** Directory name for skills within agent config */
57
+ const SKILLS_SUBDIR = 'skills';
58
+
59
+ /** Agent config directory names */
60
+ const AGENT_DIRS: Record<SkillTarget, string> = {
61
+ claude: '.claude',
62
+ codex: '.codex',
63
+ };
64
+
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+ // Path Resolution
67
+ // ─────────────────────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Resolve skill installation paths for a given scope and target.
71
+ */
72
+ export function resolveSkillPaths(opts: SkillPathOptions): SkillPaths {
73
+ const { scope, target, cwd, homeDir } = opts;
74
+
75
+ // Check for env overrides first
76
+ const envOverride =
77
+ target === 'claude'
78
+ ? process.env[ENV_CLAUDE_SKILLS_DIR]
79
+ : process.env[ENV_CODEX_SKILLS_DIR];
80
+
81
+ if (envOverride) {
82
+ // Require absolute path for security
83
+ if (!isAbsolute(envOverride)) {
84
+ throw new Error(
85
+ `${target === 'claude' ? ENV_CLAUDE_SKILLS_DIR : ENV_CODEX_SKILLS_DIR} must be an absolute path`
86
+ );
87
+ }
88
+ const skillsDir = normalize(envOverride);
89
+ return {
90
+ base: join(skillsDir, '..'),
91
+ skillsDir,
92
+ gnoDir: join(skillsDir, SKILL_NAME),
93
+ };
94
+ }
95
+
96
+ // Resolve base directory
97
+ const agentDir = AGENT_DIRS[target];
98
+ let base: string;
99
+
100
+ if (scope === 'user') {
101
+ const home = homeDir ?? process.env[ENV_SKILLS_HOME_OVERRIDE] ?? homedir();
102
+ base = join(home, agentDir);
103
+ } else {
104
+ const projectRoot = cwd ?? process.cwd();
105
+ base = join(projectRoot, agentDir);
106
+ }
107
+
108
+ const skillsDir = join(base, SKILLS_SUBDIR);
109
+ const gnoDir = join(skillsDir, SKILL_NAME);
110
+
111
+ return { base, skillsDir, gnoDir };
112
+ }
113
+
114
+ /**
115
+ * Resolve paths for all targets given scope options.
116
+ */
117
+ export function resolveAllPaths(
118
+ scope: SkillScope | 'all',
119
+ target: SkillTarget | 'all',
120
+ overrides?: { cwd?: string; homeDir?: string }
121
+ ): Array<{ scope: SkillScope; target: SkillTarget; paths: SkillPaths }> {
122
+ const scopes: SkillScope[] = scope === 'all' ? ['project', 'user'] : [scope];
123
+ const targets: SkillTarget[] =
124
+ target === 'all' ? ['claude', 'codex'] : [target];
125
+
126
+ const results: Array<{
127
+ scope: SkillScope;
128
+ target: SkillTarget;
129
+ paths: SkillPaths;
130
+ }> = [];
131
+
132
+ for (const s of scopes) {
133
+ for (const t of targets) {
134
+ results.push({
135
+ scope: s,
136
+ target: t,
137
+ paths: resolveSkillPaths({ scope: s, target: t, ...overrides }),
138
+ });
139
+ }
140
+ }
141
+
142
+ return results;
143
+ }
144
+
145
+ // ─────────────────────────────────────────────────────────────────────────────
146
+ // Safety Validation
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Expected path suffix for gno skill directory.
151
+ * Platform-aware (handles Windows backslash).
152
+ */
153
+ function getExpectedSuffix(): string {
154
+ return `${sep}${SKILLS_SUBDIR}${sep}${SKILL_NAME}`;
155
+ }
156
+
157
+ /**
158
+ * Validate that a path is safe to delete.
159
+ * Returns null if safe, or error message if unsafe.
160
+ */
161
+ export function validatePathForDeletion(
162
+ destDir: string,
163
+ base: string
164
+ ): string | null {
165
+ const normalized = normalize(destDir);
166
+ const normalizedBase = normalize(base);
167
+ const expectedSuffix = getExpectedSuffix();
168
+
169
+ // Must end with /skills/gno (or \skills\gno on Windows)
170
+ if (!normalized.endsWith(expectedSuffix)) {
171
+ return `Path does not end with expected suffix (${expectedSuffix})`;
172
+ }
173
+
174
+ // Minimum length sanity check
175
+ if (normalized.length < 20) {
176
+ return 'Path is suspiciously short';
177
+ }
178
+
179
+ // Must not equal base
180
+ if (normalized === normalizedBase) {
181
+ return 'Path equals base directory';
182
+ }
183
+
184
+ // Must be strictly inside expected base (proper containment check)
185
+ const rel = relative(normalizedBase, normalized);
186
+ if (rel.startsWith('..') || isAbsolute(rel)) {
187
+ return 'Path is not inside expected base directory';
188
+ }
189
+
190
+ return null;
191
+ }