@orxataguy/tyr 1.0.29 → 1.0.31

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.
package/README.md CHANGED
@@ -252,6 +252,8 @@ tyr deploy --debug
252
252
  │ │ ├── Kernel.ts # Command router and execution engine
253
253
  │ │ ├── Container.ts # Dependency injection container
254
254
  │ │ ├── TyrError.ts # Structured error type
255
+ │ │ ├── util
256
+ │ │ │ └── getenv.ts # Helper: get environment variables
255
257
  │ │ └── sys/
256
258
  │ │ ├── gen.ts # Built-in: scaffold a command
257
259
  │ │ ├── rem.ts # Built-in: remove a command
@@ -259,16 +261,23 @@ tyr deploy --debug
259
261
  │ ├── commands/
260
262
  │ │ └── *.tyr.ts # Your custom commands go here
261
263
  │ └── lib/
262
- ├── ShellManager.ts
263
- ├── FileSystemManager.ts
264
- ├── PackageManager.ts
265
- ├── DockerManager.ts
266
- ├── GitManager.ts
267
- ├── SystemManager.ts
268
- ├── SQLManager.ts
269
- └── WebManager.ts
270
- ├── config/
271
- └── map.yml # Command registry
264
+ ├── AIContextManager.ts
265
+ ├── AIVendorManager.ts
266
+ ├── DockerManager.ts
267
+ ├── FileSystemManager.ts
268
+ ├── GitManager.ts
269
+ ├── JiraManager.ts
270
+ ├── MongoManager.ts
271
+ │ ├── PackageManager.ts
272
+ │ │ ├── PromptTemplateManager.ts
273
+ │ ├── SetupManager.ts
274
+ │ │ ├── ShellManager.ts
275
+ │ │ ├── SQLManager.ts
276
+ │ │ ├── SystemManager.ts
277
+ │ │ ├── TokenManager.ts
278
+ │ │ ├── WebManager.ts
279
+ │ │ └── WorkspaceManager.ts
280
+ | └── index.ts # Exports context
272
281
  └── tests/
273
282
  ├── setup.ts # Mock context factory
274
283
  └── test-runner.ts # Smoke test runner
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orxataguy/tyr",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tyr": "./bin/tyr.js"
@@ -10,6 +10,10 @@ import { WebManager } from '../lib/WebManager.js';
10
10
  import { WorkspaceManager } from '../lib/WorkspaceManager.js';
11
11
  import { JiraManager } from '../lib/JiraManager.js';
12
12
  import { SetupManager } from '../lib/SetupManager.js';
13
+ import { AIVendorManager } from '../lib/AIVendorManager.js';
14
+ import { AIContextManager } from '../lib/AIContextManager.js';
15
+ import { PromptTemplateManager } from '../lib/PromptTemplateManager.js';
16
+ import { TokenManager } from '../lib/TokenManager.js';
13
17
  import { Logger, createLogger } from './Logger.js';
14
18
 
15
19
  import path from 'path';
@@ -31,6 +35,10 @@ export interface ServiceContainer {
31
35
  workspace: WorkspaceManager;
32
36
  jira: JiraManager;
33
37
  setup: SetupManager;
38
+ aiVendor: AIVendorManager;
39
+ aiContext: AIContextManager;
40
+ prompts: PromptTemplateManager;
41
+ tokens: TokenManager;
34
42
  }
35
43
 
36
44
  export class Container {
@@ -47,6 +55,8 @@ export class Container {
47
55
  const mongo = new MongoManager();
48
56
  const web = new WebManager(logger);
49
57
  const fs = new FileSystemManager(logger);
58
+ const aiVendor = new AIVendorManager(logger);
59
+ const aiContext = new AIContextManager(fs, aiVendor, logger);
50
60
 
51
61
  this.services = {
52
62
  logger,
@@ -63,6 +73,10 @@ export class Container {
63
73
  workspace: new WorkspaceManager(shell, fs, logger),
64
74
  jira: new JiraManager(web, shell, logger),
65
75
  setup: new SetupManager(shell, fs, logger),
76
+ aiVendor,
77
+ aiContext,
78
+ prompts: new PromptTemplateManager(aiContext, logger),
79
+ tokens: new TokenManager(logger),
66
80
  };
67
81
  }
68
82
 
@@ -1,4 +1,5 @@
1
1
  import path from 'path';
2
+ import { getEnvString } from '../util/getenv.js';
2
3
  import { homedir, platform } from 'os';
3
4
  import { existsSync, cpSync, rmSync, mkdirSync, readdirSync } from 'fs';
4
5
  import { execSync } from 'child_process';
@@ -48,7 +49,7 @@ function backupUserRoot(userRoot: string, backupPath: string): void {
48
49
  }
49
50
 
50
51
  function detectShellRcFile(homeDir: string): string | null {
51
- const shell = process.env.SHELL || '';
52
+ const shell = getEnvString('SHELL', '');
52
53
  if (shell.includes('zsh')) return path.join(homeDir, '.zshrc');
53
54
  if (shell.includes('fish')) return path.join(homeDir, '.config', 'fish', 'config.fish');
54
55
  if (shell.includes('bash')) {
@@ -60,7 +61,7 @@ function detectShellRcFile(homeDir: string): string | null {
60
61
  }
61
62
 
62
63
  function getWindowsProfilePaths(): string[] {
63
- const userProfile = process.env.USERPROFILE;
64
+ const userProfile = getEnvString('USERPROFILE');
64
65
  if (!userProfile) return [];
65
66
  return [
66
67
  path.join(userProfile, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1'),
@@ -123,6 +124,14 @@ MSSQL_DATABASE=
123
124
  # MongoDB database
124
125
  MONGO_URI=
125
126
  MONGO_DATABASE=
127
+ # AI vendor (anthropic | openai | gemini)
128
+ AI_VENDOR=anthropic
129
+ ANTHROPIC_API_KEY=
130
+ OPENAI_API_KEY=
131
+ GEMINI_API_KEY=
132
+ AI_MODEL=
133
+ AI_TEMPERATURE=
134
+ AI_MAX_TOKENS=
126
135
  `;
127
136
 
128
137
  const SH_ALIASES_TEMPLATE = `# Add your custom aliases here.
@@ -0,0 +1,57 @@
1
+
2
+ export function getEnvString(name: string, fallback: string): string;
3
+ export function getEnvString(name: string, fallback?: string): string | undefined;
4
+ export function getEnvString(name: string, fallback?: string): string | undefined {
5
+ const value = process.env[name];
6
+ return value === undefined || value === '' ? fallback : value;
7
+ }
8
+
9
+
10
+ export function getEnvInt(name: string, fallback: number): number;
11
+ export function getEnvInt(name: string, fallback?: number): number | undefined;
12
+ export function getEnvInt(name: string, fallback?: number): number | undefined {
13
+ const raw = getEnvString(name);
14
+ if (raw === undefined) return fallback;
15
+ const value = Number.parseInt(raw, 10);
16
+ return Number.isNaN(value) ? fallback : value;
17
+ }
18
+
19
+
20
+ export function getEnvDouble(name: string, fallback: number): number;
21
+ export function getEnvDouble(name: string, fallback?: number): number | undefined;
22
+ export function getEnvDouble(name: string, fallback?: number): number | undefined {
23
+ const raw = getEnvString(name);
24
+ if (raw === undefined) return fallback;
25
+ const value = Number.parseFloat(raw);
26
+ return Number.isNaN(value) ? fallback : value;
27
+ }
28
+
29
+ const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
30
+ const FALSE_VALUES = new Set(['0', 'false', 'no', 'off']);
31
+
32
+
33
+ export function getEnvBool(name: string, fallback: boolean): boolean;
34
+ export function getEnvBool(name: string, fallback?: boolean): boolean | undefined;
35
+ export function getEnvBool(name: string, fallback?: boolean): boolean | undefined {
36
+ const raw = getEnvString(name);
37
+ if (raw === undefined) return fallback;
38
+
39
+ const normalized = raw.trim().toLowerCase();
40
+ if (TRUE_VALUES.has(normalized)) return true;
41
+ if (FALSE_VALUES.has(normalized)) return false;
42
+ return fallback;
43
+ }
44
+
45
+ export function getEnvArray(name: string, fallback: string[], separator?: string): string[];
46
+ export function getEnvArray(name: string, fallback?: string[], separator?: string): string[] | undefined;
47
+ export function getEnvArray(name: string, fallback?: string[], separator: string = ','): string[] | undefined {
48
+ const raw = getEnvString(name);
49
+ if (raw === undefined) return fallback;
50
+
51
+ const values = raw
52
+ .split(separator)
53
+ .map((entry) => entry.trim())
54
+ .filter((entry) => entry.length > 0);
55
+
56
+ return values.length > 0 ? values : fallback;
57
+ }
@@ -0,0 +1,302 @@
1
+ import path from 'path';
2
+
3
+ import { FileSystemManager } from './FileSystemManager.js';
4
+ import { AIVendorManager, AIMessage } from './AIVendorManager.js';
5
+ import { Logger } from '../core/Logger.js';
6
+ import { TyrError } from '../core/TyrError.js';
7
+
8
+ import { getEnvInt } from '../core/util/getenv.js';
9
+
10
+
11
+ const CONTEXT_FILENAMES = [
12
+ 'CLAUDE.md',
13
+ 'AGENTS.md',
14
+ 'CONTEXT.md',
15
+ '.cursorrules',
16
+ path.join('.github', 'copilot-instructions.md'),
17
+ ];
18
+
19
+ const GENERATED_FILENAME = 'CLAUDE.md';
20
+
21
+ const SNAPSHOT_TREE_MAX_DEPTH = getEnvInt('SNAPSHOT_TREE_MAX_DEPTH', 5);
22
+ const SNAPSHOT_TREE_MAX_ENTRIES_PER_DIR = getEnvInt('SNAPSHOT_TREE_MAX_ENTRIES_PER_DIR', 250);
23
+ const SNAPSHOT_SECTION_MAX_CHARS = getEnvInt('SNAPSHOT_SECTION_MAX_CHARS', 40000);
24
+
25
+ const DEFAULT_IGNORED_DIRS = new Set<string>([
26
+ 'node_modules', '.git', '.hg', '.svn',
27
+ '.turbo', '.next', '.nuxt', '.cache', '.parcel-cache',
28
+ '.vscode', '.idea',
29
+ 'dist', 'build', 'out', 'coverage', '.nyc_output',
30
+ 'target', 'vendor',
31
+ ]);
32
+
33
+ const PACKAGE_JSON_FIELDS = ['name', 'version', 'type', 'dependencies', 'devDependencies', 'scripts'] as const;
34
+
35
+ const GUIDELINES_SYSTEM_PROMPT =
36
+ 'You are a senior software architect. Given a snapshot of a project (package manifest, README ' +
37
+ 'and file structure), produce a concise, well-structured Markdown file of coding guidelines and ' +
38
+ 'project conventions (stack, architecture, naming, testing, error handling) meant to be read by ' +
39
+ 'an AI coding assistant before making changes. Be specific to this project and avoid generic ' +
40
+ 'advice. Output only the Markdown content, with no surrounding commentary.';
41
+
42
+ interface GuidelinesBlock {
43
+ fileName: string;
44
+ content: string;
45
+ }
46
+
47
+ /**
48
+ * @class AIContextManager
49
+ * @description Encuentra, valida y lee los archivos de directrices del proyecto (CLAUDE.md,
50
+ * AGENTS.md, etc.). Si no existen, escanea el proyecto (100% Node.js nativo, sin subprocesos de
51
+ * shell) y le pide al vendor de IA configurado que genere un archivo de directrices óptimo.
52
+ * Expone tanto el texto plano de las directrices (para sesiones de chat que gestionan su propia
53
+ * ventana de contexto) como un mensaje de sistema listo para usar (para completions puntuales).
54
+ */
55
+ export class AIContextManager {
56
+ private fs: FileSystemManager;
57
+ private ai: AIVendorManager;
58
+ private logger: Logger;
59
+
60
+ constructor(fs: FileSystemManager, ai: AIVendorManager, logger: Logger) {
61
+ this.fs = fs;
62
+ this.ai = ai;
63
+ this.logger = logger;
64
+ }
65
+
66
+ /**
67
+ * @method findContextFiles
68
+ * @description Busca los archivos de directrices conocidos directamente dentro del directorio
69
+ * del proyecto.
70
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto.
71
+ * @returns {string[]} Rutas absolutas de los archivos de directrices que existen.
72
+ */
73
+ public findContextFiles(dir: string): string[] {
74
+ return CONTEXT_FILENAMES
75
+ .map(name => path.join(dir, name))
76
+ .filter(fullPath => this.fs.exists(fullPath));
77
+ }
78
+
79
+ private async readAndValidate(filePath: string): Promise<string | null> {
80
+ const content = await this.fs.read(filePath);
81
+ if (!content || !content.trim()) return null;
82
+ return content.trim();
83
+ }
84
+
85
+ /**
86
+ * @method buildPackageJsonSummary
87
+ * @description Parsea package.json de forma segura (try/catch) y vuelve a serializar
88
+ * únicamente los campos relevantes para el contexto arquitectónico. A diferencia de un
89
+ * `.slice()` a ciegas, nunca entrega al vendor de IA un JSON truncado y sintácticamente roto.
90
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto.
91
+ * @returns {Promise<string | null>} Sección Markdown lista para el snapshot, o null si no
92
+ * hay package.json legible o válido.
93
+ */
94
+ private async buildPackageJsonSummary(dir: string): Promise<string | null> {
95
+ const pkgPath = path.join(dir, 'package.json');
96
+ if (!this.fs.exists(pkgPath)) return null;
97
+
98
+ const raw = await this.fs.read(pkgPath);
99
+ if (!raw) return null;
100
+
101
+ try {
102
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
103
+ const summary: Record<string, unknown> = {};
104
+ for (const field of PACKAGE_JSON_FIELDS) {
105
+ if (parsed[field] !== undefined) summary[field] = parsed[field];
106
+ }
107
+ return `# package.json (summary)\n\`\`\`json\n${JSON.stringify(summary, null, 2)}\n\`\`\``;
108
+ } catch {
109
+ this.logger.info(`package.json en ${pkgPath} no es JSON válido, se omite del snapshot.`);
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * @method scanDirectoryTree
116
+ * @description Escaneo recursivo de directorios agnóstico al SO usando la API nativa de
117
+ * Node.js (sin subprocesos de shell, por lo que funciona igual en Windows, macOS y Linux).
118
+ * Genera un árbol de texto indentado, ignora directorios de ruido y limita las entradas por
119
+ * carpeta para que directorios con cientos de archivos generados no colapsen el snapshot.
120
+ * @param {string} rootDir - Ruta absoluta desde la que empezar a escanear.
121
+ * @param {number} maxDepth - Profundidad máxima de recursión.
122
+ * @param {number} maxEntriesPerDir - Máximo de entradas listadas por carpeta antes de truncar.
123
+ * @param {Set<string>} ignoredDirs - Nombres de directorios a omitir por completo.
124
+ * @returns {Promise<string>} Representación en texto indentado del árbol.
125
+ */
126
+ private async scanDirectoryTree(
127
+ rootDir: string,
128
+ maxDepth: number = SNAPSHOT_TREE_MAX_DEPTH,
129
+ maxEntriesPerDir: number = SNAPSHOT_TREE_MAX_ENTRIES_PER_DIR,
130
+ ignoredDirs: Set<string> = DEFAULT_IGNORED_DIRS
131
+ ): Promise<string> {
132
+ const lines: string[] = [path.basename(rootDir) || rootDir];
133
+
134
+ const walk = async (currentDir: string, depth: number, prefix: string): Promise<void> => {
135
+ if (depth > maxDepth) return;
136
+
137
+ let entries;
138
+ try {
139
+ entries = await this.fs.readdir(currentDir, { withFileTypes: true });
140
+ } catch {
141
+ // Errores de permisos o carpetas borradas a mitad de escaneo no deben abortar
142
+ // todo el snapshot, simplemente se omiten.
143
+ return;
144
+ }
145
+
146
+ entries = entries
147
+ .filter(entry => !(entry.isDirectory() && ignoredDirs.has(entry.name)))
148
+ .sort((a, b) => {
149
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
150
+ return a.name.localeCompare(b.name);
151
+ });
152
+
153
+ const visible = entries.slice(0, maxEntriesPerDir);
154
+ const hiddenCount = entries.length - visible.length;
155
+
156
+ for (const entry of visible) {
157
+ lines.push(`${prefix}${entry.name}${entry.isDirectory() ? '/' : ''}`);
158
+ if (entry.isDirectory()) {
159
+ await walk(path.join(currentDir, entry.name), depth + 1, `${prefix} `);
160
+ }
161
+ }
162
+
163
+ if (hiddenCount > 0) {
164
+ lines.push(`${prefix}… y ${hiddenCount} elemento(s) más`);
165
+ }
166
+ };
167
+
168
+ await walk(rootDir, 1, ' ');
169
+ return lines.join('\n');
170
+ }
171
+
172
+ /**
173
+ * @method buildProjectSnapshot
174
+ * @description Ensambla el material que necesita el vendor de IA para escribir directrices:
175
+ * un resumen seguro de package.json, el README y un árbol de directorios nativo. Sin
176
+ * subprocesos de shell.
177
+ */
178
+ private async buildProjectSnapshot(dir: string): Promise<string> {
179
+ const parts: string[] = [];
180
+
181
+ const pkgSummary = await this.buildPackageJsonSummary(dir);
182
+ if (pkgSummary) parts.push(pkgSummary);
183
+
184
+ const readmePath = path.join(dir, 'README.md');
185
+ if (this.fs.exists(readmePath)) {
186
+ const readme = await this.fs.read(readmePath);
187
+ // El README es texto libre, no estructurado: aquí el truncado por longitud sí es seguro.
188
+ if (readme) parts.push(`# README.md\n${readme.slice(0, SNAPSHOT_SECTION_MAX_CHARS)}`);
189
+ }
190
+
191
+ const tree = await this.scanDirectoryTree(dir).catch(() => '');
192
+ if (tree) parts.push(`# Project structure\n${tree.slice(0, SNAPSHOT_SECTION_MAX_CHARS)}`);
193
+
194
+ return parts.join('\n\n');
195
+ }
196
+
197
+ /**
198
+ * @method generateContextFile
199
+ * @description Escanea el proyecto y le pide al vendor de IA configurado que sintetice un
200
+ * archivo de directrices, luego lo escribe como CLAUDE.md en la raíz del proyecto.
201
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto.
202
+ * @returns {Promise<string>} Ruta absoluta del archivo generado.
203
+ */
204
+ public async generateContextFile(dir: string): Promise<string> {
205
+ this.logger.info('No se encontró archivo de contexto. Analizando el proyecto para generar uno...');
206
+
207
+ const snapshot = await this.buildProjectSnapshot(dir);
208
+ const messages: AIMessage[] = [
209
+ { role: 'system', content: GUIDELINES_SYSTEM_PROMPT },
210
+ { role: 'user', content: snapshot || 'The project has no readable package.json, README or file tree.' },
211
+ ];
212
+
213
+ let result;
214
+ try {
215
+ result = await this.ai.complete(messages, { temperature: 0.2 });
216
+ } catch (err) {
217
+ throw new TyrError(
218
+ `No se pudo generar el archivo de contexto para ${dir}`,
219
+ err,
220
+ 'Revisa la configuración de tu vendor de IA y la conectividad de red, luego reintenta.'
221
+ );
222
+ }
223
+
224
+ const targetPath = path.join(dir, GENERATED_FILENAME);
225
+ await this.fs.write(targetPath, result.content.trim() + '\n');
226
+
227
+ this.logger.success(`Archivo de contexto generado en: ${targetPath}`);
228
+ return targetPath;
229
+ }
230
+
231
+ /**
232
+ * @method readGuidelines
233
+ * @description Encuentra los archivos de directrices existentes (generando uno vía IA si no
234
+ * hay ninguno), los lee y valida, y los devuelve como bloques discretos etiquetados — sin
235
+ * colapsarlos en un único string de mensaje de sistema. Esta es la pieza clave para sesiones
236
+ * de chat de larga duración: quien necesite gestionar una ventana de contexto continua (p. ej.
237
+ * un AIChatSessionManager) puede inspeccionar, cachear o descartar bloques individuales en
238
+ * lugar de releer disco y reinyectar un system prompt duplicado en cada turno.
239
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto.
240
+ * @returns {Promise<GuidelinesBlock[]>}
241
+ */
242
+ public async readGuidelines(dir: string): Promise<GuidelinesBlock[]> {
243
+ let files = this.findContextFiles(dir);
244
+
245
+ if (files.length === 0) {
246
+ const generated = await this.generateContextFile(dir);
247
+ files = [generated];
248
+ }
249
+
250
+ const blocks: GuidelinesBlock[] = [];
251
+ for (const file of files) {
252
+ const content = await this.readAndValidate(file);
253
+ if (content) blocks.push({ fileName: path.basename(file), content });
254
+ }
255
+
256
+ if (blocks.length === 0) {
257
+ throw new TyrError(
258
+ `Se encontraron archivos de contexto pero están vacíos o son ilegibles en: ${dir}`,
259
+ null,
260
+ 'Revisa el contenido de tu archivo CLAUDE.md/AGENTS.md.'
261
+ );
262
+ }
263
+
264
+ return blocks;
265
+ }
266
+
267
+ /**
268
+ * @method getGuidelinesText
269
+ * @description Envoltorio de conveniencia sobre readGuidelines() que devuelve el texto plano
270
+ * combinado de las directrices, sin envolver en roles/mensajes. Pensado para un
271
+ * AIChatSessionManager: se obtiene una vez por sesión, se cachea, y se gestiona dentro de la
272
+ * ventana de contexto de la conversación en lugar de llamar a getContext() (con su relectura
273
+ * de disco y reenvío de un system message completo) en cada turno.
274
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto.
275
+ * @returns {Promise<string>}
276
+ */
277
+ public async getGuidelinesText(dir: string): Promise<string> {
278
+ const blocks = await this.readGuidelines(dir);
279
+ return blocks.map(b => `<!-- ${b.fileName} -->\n${b.content}`).join('\n\n');
280
+ }
281
+
282
+ /**
283
+ * @method getContext
284
+ * @description Punto de entrada para uso puntual: encuentra/genera archivos de directrices y
285
+ * los empaqueta como un único mensaje 'system' listo para prepender a un prompt. Para sesiones
286
+ * de chat continuas, usa getGuidelinesText() junto con tu propia gestión de ventana de contexto
287
+ * en lugar de llamar a este método en cada turno.
288
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto.
289
+ * @returns {Promise<AIMessage[]>}
290
+ * @example
291
+ * const contextMessages = await context.getContext(process.cwd());
292
+ * const result = await ai.complete([...contextMessages, { role: 'user', content: 'Fix this bug...' }]);
293
+ */
294
+ public async getContext(dir: string): Promise<AIMessage[]> {
295
+ const text = await this.getGuidelinesText(dir);
296
+ return [{ role: 'system', content: text }];
297
+ }
298
+ }
299
+
300
+ export const AIContextManagerTests = {
301
+ findContextFiles: { dir: '~/Projects/TyrFramework' },
302
+ };