@axplusb/kepler 1.0.5 → 1.0.10

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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Portable skill installation and lock metadata.
3
+ *
4
+ * Bundles are copied as data. No scripts or hooks from a skill are executed.
5
+ */
6
+
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { execFileSync } from 'node:child_process';
12
+ import { discoverSkillDirectories, parseSkill } from './loader.mjs';
13
+
14
+ function isGitSource(source) {
15
+ return /^(https?:\/\/|ssh:\/\/|git@|github:)/.test(source) || source.endsWith('.git');
16
+ }
17
+
18
+ function normalizeGitSource(source) {
19
+ if (source.startsWith('github:')) {
20
+ return `https://github.com/${source.slice('github:'.length).replace(/\.git$/, '')}.git`;
21
+ }
22
+ return source;
23
+ }
24
+
25
+ function discoverInstallableSkills(root, maxDepth = 4) {
26
+ const direct = discoverSkillDirectories(root);
27
+ if (direct.length) return direct;
28
+
29
+ const found = [];
30
+ const queue = [{ dir: root, depth: 0 }];
31
+ const ignored = new Set(['.git', 'node_modules', '.venv', 'venv', 'dist', 'build']);
32
+ while (queue.length) {
33
+ const { dir, depth } = queue.shift();
34
+ if (depth >= maxDepth) continue;
35
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
36
+ if (!entry.isDirectory() || entry.isSymbolicLink() || ignored.has(entry.name)) continue;
37
+ const child = path.join(dir, entry.name);
38
+ if (fs.existsSync(path.join(child, 'SKILL.md'))) {
39
+ found.push(child);
40
+ } else {
41
+ queue.push({ dir: child, depth: depth + 1 });
42
+ }
43
+ }
44
+ }
45
+ return found.sort();
46
+ }
47
+
48
+ function rejectSymlinks(root) {
49
+ const queue = [root];
50
+ while (queue.length) {
51
+ const current = queue.shift();
52
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
53
+ const fullPath = path.join(current, entry.name);
54
+ if (entry.isSymbolicLink()) throw new Error(`Symlinked skill resource is not allowed: ${fullPath}`);
55
+ if (entry.isDirectory()) queue.push(fullPath);
56
+ }
57
+ }
58
+ }
59
+
60
+ function copyDirectory(source, destination) {
61
+ fs.mkdirSync(destination, { recursive: true });
62
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
63
+ const from = path.join(source, entry.name);
64
+ const to = path.join(destination, entry.name);
65
+ if (entry.isDirectory()) copyDirectory(from, to);
66
+ else if (entry.isFile()) fs.copyFileSync(from, to);
67
+ }
68
+ }
69
+
70
+ function readJson(file, fallback) {
71
+ try { return JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return fallback; }
72
+ }
73
+
74
+ function writeJson(file, value) {
75
+ fs.mkdirSync(path.dirname(file), { recursive: true });
76
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
77
+ }
78
+
79
+ export class SkillInstaller {
80
+ constructor({ cwd = process.cwd(), homeDir = os.homedir() } = {}) {
81
+ this.cwd = path.resolve(cwd);
82
+ this.homeDir = homeDir;
83
+ }
84
+
85
+ paths(scope = 'global') {
86
+ const base = scope === 'project'
87
+ ? path.join(this.cwd, '.kepler')
88
+ : path.join(this.homeDir, '.kepler');
89
+ return {
90
+ skillsDir: path.join(base, 'skills'),
91
+ lockFile: path.join(base, 'skills.lock.json'),
92
+ };
93
+ }
94
+
95
+ install(source, { scope = 'global', force = false, onlyNames = null } = {}) {
96
+ if (!source) throw new Error('A local directory or Git repository is required');
97
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kepler-skill-'));
98
+ let resolvedSource;
99
+ let commit = null;
100
+ try {
101
+ if (isGitSource(source)) {
102
+ resolvedSource = path.join(tempRoot, 'repository');
103
+ execFileSync(
104
+ 'git',
105
+ ['clone', '--depth', '1', normalizeGitSource(source), resolvedSource],
106
+ { stdio: 'pipe' },
107
+ );
108
+ commit = execFileSync('git', ['-C', resolvedSource, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
109
+ } else {
110
+ resolvedSource = fs.realpathSync(path.resolve(this.cwd, source));
111
+ }
112
+
113
+ const skillDirs = discoverInstallableSkills(resolvedSource);
114
+ if (!skillDirs.length) throw new Error(`No SKILL.md bundles found in ${source}`);
115
+
116
+ const { skillsDir, lockFile } = this.paths(scope);
117
+ const lock = readJson(lockFile, { version: 1, skills: {} });
118
+ const installed = [];
119
+ fs.mkdirSync(skillsDir, { recursive: true });
120
+
121
+ const plans = [];
122
+ for (const skillDir of skillDirs) {
123
+ rejectSymlinks(skillDir);
124
+ const content = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
125
+ const name = parseSkill(content, path.basename(skillDir)).name;
126
+ if (onlyNames && !onlyNames.includes(name)) continue;
127
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) throw new Error(`Unsafe skill name: ${name}`);
128
+ const destination = path.join(skillsDir, name);
129
+ if (fs.existsSync(destination)) {
130
+ if (!force) throw new Error(`Skill already installed: ${name} (use --force to replace)`);
131
+ }
132
+ plans.push({ skillDir, content, name, destination });
133
+ }
134
+ if (!plans.length) {
135
+ throw new Error(
136
+ onlyNames
137
+ ? `Requested skill not found in source: ${onlyNames.join(', ')}`
138
+ : `No installable skills found in ${source}`,
139
+ );
140
+ }
141
+
142
+ for (const { skillDir, content, name, destination } of plans) {
143
+ if (fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
144
+ copyDirectory(skillDir, destination);
145
+
146
+ const digest = crypto.createHash('sha256')
147
+ .update(content)
148
+ .digest('hex');
149
+ lock.skills[name] = {
150
+ source,
151
+ scope,
152
+ commit,
153
+ destination,
154
+ installed_at: new Date().toISOString(),
155
+ manifest_hash: `sha256:${digest}`,
156
+ };
157
+ installed.push(name);
158
+ }
159
+ writeJson(lockFile, lock);
160
+ return { installed, scope, lock_file: lockFile };
161
+ } finally {
162
+ fs.rmSync(tempRoot, { recursive: true, force: true });
163
+ }
164
+ }
165
+
166
+ remove(name, { scope = 'global' } = {}) {
167
+ const { skillsDir, lockFile } = this.paths(scope);
168
+ const destination = path.join(skillsDir, name);
169
+ if (!fs.existsSync(destination)) throw new Error(`Skill is not installed: ${name}`);
170
+ fs.rmSync(destination, { recursive: true, force: true });
171
+ const lock = readJson(lockFile, { version: 1, skills: {} });
172
+ delete lock.skills[name];
173
+ writeJson(lockFile, lock);
174
+ return { removed: name, scope };
175
+ }
176
+
177
+ update(name, { scope = 'global' } = {}) {
178
+ const { lockFile } = this.paths(scope);
179
+ const lock = readJson(lockFile, { version: 1, skills: {} });
180
+ const entry = lock.skills[name];
181
+ if (!entry?.source) throw new Error(`No locked source for skill: ${name}`);
182
+ return this.install(entry.source, { scope, force: true, onlyNames: [name] });
183
+ }
184
+
185
+ lock(scope = 'global') {
186
+ return readJson(this.paths(scope).lockFile, { version: 1, skills: {} });
187
+ }
188
+ }
@@ -1,147 +1,252 @@
1
1
  /**
2
- * Skills Loader loads skills from .claude/skills/{name}/SKILL.md
2
+ * Portable Agent Skills loader.
3
3
  *
4
- * Skills are invoked via /skill-name in REPL or the Skill tool.
5
- * Each skill has a SKILL.md that defines:
6
- * - name, description, trigger conditions
7
- * - The prompt to inject when the skill is invoked
4
+ * Discovers standard <root>/<skill>/SKILL.md bundles from Kepler and Claude
5
+ * locations. Only metadata is exposed eagerly; instructions and resources are
6
+ * loaded on demand through view().
8
7
  */
9
8
 
10
- import fs from 'fs';
11
- import path from 'path';
9
+ import crypto from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ const DEFAULT_MAX_CHARS = 12_000;
15
+
16
+ function isWithin(root, candidate) {
17
+ const relative = path.relative(root, candidate);
18
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
19
+ }
20
+
21
+ function parseScalar(value) {
22
+ const trimmed = value.trim();
23
+ if (
24
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))
25
+ || (trimmed.startsWith("'") && trimmed.endsWith("'"))
26
+ ) {
27
+ return trimmed.slice(1, -1);
28
+ }
29
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
30
+ return trimmed.slice(1, -1).split(',').map(item => parseScalar(item)).filter(Boolean);
31
+ }
32
+ if (trimmed === 'true') return true;
33
+ if (trimmed === 'false') return false;
34
+ return trimmed;
35
+ }
36
+
37
+ export function parseSkill(content, fallbackName) {
38
+ const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/);
39
+ if (!match) throw new Error('SKILL.md requires YAML frontmatter');
40
+
41
+ const metadata = {};
42
+ let activeList = null;
43
+ for (const rawLine of match[1].split(/\r?\n/)) {
44
+ if (!rawLine.trim() || rawLine.trimStart().startsWith('#')) continue;
45
+ const listMatch = rawLine.match(/^\s*-\s+(.+)$/);
46
+ if (listMatch && activeList) {
47
+ metadata[activeList].push(parseScalar(listMatch[1]));
48
+ continue;
49
+ }
50
+ const fieldMatch = rawLine.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
51
+ if (!fieldMatch) continue;
52
+ const [, key, rawValue] = fieldMatch;
53
+ if (!rawValue.trim()) {
54
+ metadata[key] = [];
55
+ activeList = key;
56
+ } else {
57
+ metadata[key] = parseScalar(rawValue);
58
+ activeList = null;
59
+ }
60
+ }
61
+
62
+ const name = String(metadata.name || fallbackName || '').trim();
63
+ const description = String(metadata.description || '').trim();
64
+ const instructions = content.slice(match[0].length).trim();
65
+ if (!name) throw new Error("SKILL.md requires non-empty 'name'");
66
+ if (!description) throw new Error("SKILL.md requires non-empty 'description'");
67
+ if (!instructions) throw new Error('SKILL.md requires instruction content');
68
+
69
+ return {
70
+ name,
71
+ description,
72
+ aliases: Array.isArray(metadata.aliases) ? metadata.aliases : [],
73
+ compatibility: Array.isArray(metadata.compatibility) ? metadata.compatibility : [],
74
+ metadata,
75
+ instructions,
76
+ prompt: instructions,
77
+ };
78
+ }
79
+
80
+ export function discoverSkillDirectories(root) {
81
+ if (!root || !fs.existsSync(root)) return [];
82
+ const resolved = fs.realpathSync(root);
83
+ if (fs.existsSync(path.join(resolved, 'SKILL.md'))) return [resolved];
84
+ return fs.readdirSync(resolved, { withFileTypes: true })
85
+ .filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
86
+ .map(entry => path.join(resolved, entry.name))
87
+ .filter(dir => fs.existsSync(path.join(dir, 'SKILL.md')))
88
+ .sort();
89
+ }
90
+
91
+ function walkFiles(root) {
92
+ const files = [];
93
+ const queue = [root];
94
+ while (queue.length) {
95
+ const current = queue.shift();
96
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
97
+ const fullPath = path.join(current, entry.name);
98
+ if (entry.isSymbolicLink()) continue;
99
+ if (entry.isDirectory()) queue.push(fullPath);
100
+ else if (entry.isFile()) files.push(fullPath);
101
+ }
102
+ }
103
+ return files.sort((a, b) => a.localeCompare(b));
104
+ }
105
+
106
+ function contentHash(root) {
107
+ const digest = crypto.createHash('sha256');
108
+ for (const filePath of walkFiles(root)) {
109
+ digest.update(path.relative(root, filePath).split(path.sep).join('/'));
110
+ digest.update('\0');
111
+ digest.update(fs.readFileSync(filePath));
112
+ digest.update('\0');
113
+ }
114
+ return `sha256:${digest.digest('hex')}`;
115
+ }
116
+
117
+ function bounded(content, maxChars) {
118
+ if (content.length <= maxChars) return content;
119
+ const head = Math.floor(maxChars * 0.7);
120
+ const tail = maxChars - head;
121
+ return `${content.slice(0, head)}\n\n[... skill content truncated ...]\n\n${content.slice(-tail)}`;
122
+ }
12
123
 
13
124
  export class SkillsLoader {
14
- constructor() {
125
+ constructor({ homeDir = os.homedir() } = {}) {
126
+ this.homeDir = homeDir;
15
127
  this.skills = new Map();
128
+ this.versions = new Map();
16
129
  this.searchPaths = [];
17
130
  }
18
131
 
19
- /**
20
- * Load skills from standard directories.
21
- * @param {string} [cwd] - project working directory
22
- */
23
132
  load(cwd = process.cwd()) {
133
+ this.skills.clear();
134
+ this.versions.clear();
135
+ const project = path.resolve(cwd);
24
136
  this.searchPaths = [
25
- path.join(cwd, '.claude', 'skills'),
26
- path.join(process.env.HOME || '', '.claude', 'skills'),
137
+ { dir: path.join(project, '.kepler', 'skills'), source: 'kepler-project', scope: 'project', priority: 500 },
138
+ { dir: path.join(project, '.claude', 'skills'), source: 'claude-project', scope: 'project', priority: 400 },
139
+ { dir: path.join(this.homeDir, '.kepler', 'skills'), source: 'kepler-global', scope: 'global', priority: 300 },
140
+ { dir: path.join(this.homeDir, '.claude', 'skills'), source: 'claude-global', scope: 'global', priority: 200 },
27
141
  ];
28
142
 
29
- for (const dir of this.searchPaths) {
30
- this._loadFromDir(dir);
31
- }
32
-
143
+ for (const config of this.searchPaths) this._loadFromDir(config);
33
144
  return this;
34
145
  }
35
146
 
36
- _loadFromDir(dir) {
37
- try {
38
- const entries = fs.readdirSync(dir, { withFileTypes: true });
39
- for (const entry of entries) {
40
- if (!entry.isDirectory()) continue;
41
-
42
- const skillFile = path.join(dir, entry.name, 'SKILL.md');
43
- try {
44
- const content = fs.readFileSync(skillFile, 'utf-8');
45
- const skill = parseSkill(content, entry.name);
46
- if (skill) {
47
- skill.source = skillFile;
48
- this.skills.set(skill.name, skill);
49
- }
50
- } catch {
51
- // Skill directory without SKILL.md, skip
52
- }
147
+ _loadFromDir(config) {
148
+ for (const skillDir of discoverSkillDirectories(config.dir)) {
149
+ try {
150
+ const skillFile = path.join(skillDir, 'SKILL.md');
151
+ if (fs.lstatSync(skillFile).isSymbolicLink()) continue;
152
+ const parsed = parseSkill(fs.readFileSync(skillFile, 'utf-8'), path.basename(skillDir));
153
+ const skill = {
154
+ ...parsed,
155
+ root: skillDir,
156
+ source: config.source,
157
+ source_id: `${config.source}:${parsed.name}`,
158
+ scope: config.scope,
159
+ priority: config.priority,
160
+ content_hash: contentHash(skillDir),
161
+ };
162
+ const versions = this.versions.get(skill.name) || [];
163
+ versions.push(skill);
164
+ versions.sort((a, b) => b.priority - a.priority || a.source.localeCompare(b.source));
165
+ this.versions.set(skill.name, versions);
166
+ this.skills.set(skill.name, versions[0]);
167
+ } catch {
168
+ // Invalid third-party bundles are ignored during discovery.
53
169
  }
54
- } catch {
55
- // Directory does not exist
56
170
  }
57
171
  }
58
172
 
59
- /**
60
- * Get a skill by name.
61
- * @param {string} name
62
- * @returns {object|null}
63
- */
64
- get(name) {
65
- // Try exact match, then prefix match
173
+ get(name, sourceId = null) {
174
+ if (sourceId) {
175
+ return (this.versions.get(name) || []).find(skill => skill.source_id === sourceId) || null;
176
+ }
66
177
  if (this.skills.has(name)) return this.skills.get(name);
67
- for (const [key, skill] of this.skills) {
68
- if (key.startsWith(name) || skill.aliases?.includes(name)) {
69
- return skill;
70
- }
178
+ for (const skill of this.skills.values()) {
179
+ if (skill.name.startsWith(name) || skill.aliases.includes(name)) return skill;
71
180
  }
72
181
  return null;
73
182
  }
74
183
 
75
- /**
76
- * List all loaded skills.
77
- * @returns {Array<object>}
78
- */
79
- list() {
80
- return [...this.skills.values()];
184
+ list({ query = '', source = '', scope = '' } = {}) {
185
+ const needle = query.toLowerCase();
186
+ return [...this.skills.values()]
187
+ .filter(skill => !source || skill.source === source)
188
+ .filter(skill => !scope || skill.scope === scope)
189
+ .filter(skill => !needle || `${skill.name} ${skill.description}`.toLowerCase().includes(needle))
190
+ .sort((a, b) => a.name.localeCompare(b.name))
191
+ .map(skill => ({
192
+ name: skill.name,
193
+ description: skill.description,
194
+ source: skill.source,
195
+ source_id: skill.source_id,
196
+ scope: skill.scope,
197
+ content_hash: skill.content_hash,
198
+ compatibility: skill.compatibility,
199
+ shadowed_sources: (this.versions.get(skill.name) || []).slice(1).map(item => item.source_id),
200
+ }));
81
201
  }
82
202
 
83
- /**
84
- * Run a skill, returning its prompt for injection into the conversation.
85
- * @param {string} name - skill name
86
- * @param {string} [args] - optional arguments
87
- * @returns {string} skill prompt
88
- */
89
- async run(name, args) {
90
- const skill = this.get(name);
91
- if (!skill) {
92
- throw new Error(`Unknown skill: ${name}`);
93
- }
94
-
95
- let prompt = skill.prompt;
96
- if (args) {
97
- prompt = prompt.replace('$ARGUMENTS', args);
98
- prompt += `\n\nArguments: ${args}`;
203
+ view(name, resourcePath = null, { sourceId = null, maxChars = DEFAULT_MAX_CHARS } = {}) {
204
+ const skill = this.get(name, sourceId);
205
+ if (!skill) throw new Error(`Unknown skill: ${name}`);
206
+ if (!resourcePath) {
207
+ return {
208
+ name: skill.name,
209
+ description: skill.description,
210
+ source: skill.source,
211
+ source_id: skill.source_id,
212
+ scope: skill.scope,
213
+ content_hash: skill.content_hash,
214
+ compatibility: skill.compatibility,
215
+ shadowed_sources: (this.versions.get(skill.name) || [])
216
+ .filter(item => item.source_id !== skill.source_id)
217
+ .map(item => item.source_id),
218
+ instructions: bounded(skill.instructions, maxChars),
219
+ resources: walkFiles(skill.root)
220
+ .map(file => path.relative(skill.root, file).split(path.sep).join('/'))
221
+ .filter(file => file !== 'SKILL.md'),
222
+ metadata: skill.metadata,
223
+ };
99
224
  }
100
225
 
101
- return `[Skill: ${skill.name}]\n${prompt}`;
102
- }
103
- }
104
-
105
- /**
106
- * Parse a SKILL.md file into a skill definition.
107
- */
108
- function parseSkill(content, dirName) {
109
- const lines = content.split('\n');
110
- const skill = {
111
- name: dirName,
112
- description: '',
113
- aliases: [],
114
- trigger: null,
115
- prompt: content,
116
- };
117
-
118
- // Parse YAML frontmatter if present
119
- if (lines[0]?.trim() === '---') {
120
- const endIdx = lines.indexOf('---', 1);
121
- if (endIdx > 0) {
122
- const frontmatter = lines.slice(1, endIdx).join('\n');
123
- for (const line of frontmatter.split('\n')) {
124
- const colonIdx = line.indexOf(':');
125
- if (colonIdx === -1) continue;
126
- const key = line.slice(0, colonIdx).trim();
127
- const value = line.slice(colonIdx + 1).trim();
128
-
129
- if (key === 'name') skill.name = value;
130
- else if (key === 'description') skill.description = value;
131
- else if (key === 'trigger') skill.trigger = value;
132
- else if (key === 'aliases') {
133
- skill.aliases = value.replace(/[\[\]]/g, '').split(',').map(s => s.trim());
134
- }
135
- }
136
- skill.prompt = lines.slice(endIdx + 1).join('\n').trim();
226
+ if (path.isAbsolute(resourcePath)) throw new Error('Skill resource path must be relative');
227
+ const candidate = path.resolve(skill.root, resourcePath);
228
+ if (!isWithin(skill.root, candidate)) throw new Error('Skill resource escapes its bundle');
229
+ const relativeParts = path.relative(skill.root, candidate).split(path.sep);
230
+ let cursor = skill.root;
231
+ for (const part of relativeParts) {
232
+ cursor = path.join(cursor, part);
233
+ if (!fs.existsSync(cursor)) throw new Error(`Skill resource not found: ${resourcePath}`);
234
+ if (fs.lstatSync(cursor).isSymbolicLink()) throw new Error('Symlinked skill resources are not allowed');
137
235
  }
236
+ if (!fs.statSync(candidate).isFile()) throw new Error(`Skill resource not found: ${resourcePath}`);
237
+ return {
238
+ name: skill.name,
239
+ source_id: skill.source_id,
240
+ path: resourcePath,
241
+ content: bounded(fs.readFileSync(candidate, 'utf-8'), maxChars),
242
+ };
138
243
  }
139
244
 
140
- // Extract description from first paragraph if not in frontmatter
141
- if (!skill.description && skill.prompt) {
142
- const firstLine = skill.prompt.split('\n').find(l => l.trim() && !l.startsWith('#'));
143
- if (firstLine) skill.description = firstLine.trim().slice(0, 100);
245
+ async run(name, args) {
246
+ const skill = this.get(name);
247
+ if (!skill) throw new Error(`Unknown skill: ${name}`);
248
+ let prompt = skill.instructions;
249
+ if (args) prompt = prompt.replace(/\$ARGUMENTS/g, args);
250
+ return `[Skill: ${skill.name}]\n${prompt}${args ? `\n\nArguments: ${args}` : ''}`;
144
251
  }
145
-
146
- return skill;
147
252
  }
@@ -399,7 +399,7 @@ export function formatElapsed(startMs) {
399
399
 
400
400
  // ── Format Cost ──
401
401
 
402
- import { calculateCost, formatCostValue } from '../core/pricing.mjs';
402
+ import { calculateCost, formatCostValue, costToCredits, formatCredits } from '../core/pricing.mjs';
403
403
 
404
404
  /**
405
405
  * Format cost from token counts.
@@ -407,15 +407,13 @@ import { calculateCost, formatCostValue } from '../core/pricing.mjs';
407
407
  * or a single usage object with optional per-model breakdown.
408
408
  */
409
409
  export function formatCost(inputOrUsage, outputTokens) {
410
- // New API: pass a usage object directly
411
410
  if (typeof inputOrUsage === 'object' && inputOrUsage !== null) {
412
411
  const { total } = calculateCost(inputOrUsage);
413
- return formatCostValue(total);
412
+ return formatCredits(costToCredits(total));
414
413
  }
415
- // Legacy API: flat input/output token counts, default pricing
416
414
  const { total } = calculateCost({
417
415
  input_tokens: inputOrUsage || 0,
418
416
  output_tokens: outputTokens || 0,
419
417
  });
420
- return formatCostValue(total);
418
+ return formatCredits(costToCredits(total));
421
419
  }
@@ -47,6 +47,17 @@ async function main() {
47
47
  return;
48
48
  }
49
49
 
50
+ if (subcommand === 'skills' || subcommand === 'skill') {
51
+ const { runSkillsCommand } = await import('./skills.mjs');
52
+ try {
53
+ await runSkillsCommand(subcommandArgs);
54
+ } catch (err) {
55
+ process.stderr.write(`\x1b[31m✗ Skills command failed: ${err.message}\x1b[0m\n`);
56
+ process.exitCode = 1;
57
+ }
58
+ return;
59
+ }
60
+
50
61
  if (subcommand === 'login') {
51
62
  const { TarangAuth } = await import('../auth/tarang-auth.mjs');
52
63
  const auth = new TarangAuth();
@@ -99,6 +110,13 @@ async function main() {
99
110
  kepler stats Show aggregate local session stats
100
111
  kepler history Show recent prompt history
101
112
 
113
+ \x1b[1mSkills:\x1b[0m
114
+ kepler skills list [--all|--project]
115
+ kepler skills view <name> [resource]
116
+ kepler skills install <path-or-git-url> [--project] [--force]
117
+ kepler skills update <name> [--project]
118
+ kepler skills remove <name> [--project]
119
+
102
120
  \x1b[1mREPL Commands:\x1b[0m
103
121
  /help Show available commands
104
122
  /stats Session metrics (tokens, cost, tools)