navori 0.1.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.
- package/README.md +144 -0
- package/dist/assets/core/core-assets/managed/cierre-sesion.md +9 -0
- package/dist/assets/core/core-assets/managed/formato-respuesta.md +12 -0
- package/dist/assets/core/core-assets/managed/idioma-rol.md +4 -0
- package/dist/assets/core/core-assets/managed/tipado-fuerte.md +5 -0
- package/dist/assets/core/package.json +17 -0
- package/dist/assets/plugins/acli/managed/acli-protocol.md +10 -0
- package/dist/assets/plugins/acli/plugin.json +21 -0
- package/dist/assets/plugins/cognitive/managed/cognitive-protocol.md +9 -0
- package/dist/assets/plugins/cognitive/plugin.json +13 -0
- package/dist/assets/plugins/engram/managed/engram-protocol.md +5 -0
- package/dist/assets/plugins/engram/plugin.json +22 -0
- package/dist/assets/plugins/gh/managed/gh-protocol.md +12 -0
- package/dist/assets/plugins/gh/plugin.json +23 -0
- package/dist/assets/plugins/jscpd/managed/jscpd-protocol.md +10 -0
- package/dist/assets/plugins/jscpd/plugin.json +22 -0
- package/dist/assets/plugins/semgrep/managed/semgrep-protocol.md +14 -0
- package/dist/assets/plugins/semgrep/plugin.json +21 -0
- package/dist/index.js +3944 -0
- package/package.json +52 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3944 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { defineCommand as defineCommand12, runMain } from "citty";
|
|
5
|
+
import { readFileSync as readFileSync15 } from "fs";
|
|
6
|
+
import { dirname as dirname7, resolve as resolve16 } from "path";
|
|
7
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
8
|
+
|
|
9
|
+
// src/commands/init.ts
|
|
10
|
+
import { defineCommand as defineCommand2 } from "citty";
|
|
11
|
+
import * as p2 from "@clack/prompts";
|
|
12
|
+
import { resolve as resolve7 } from "path";
|
|
13
|
+
import { existsSync as existsSync10 } from "fs";
|
|
14
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
15
|
+
|
|
16
|
+
// src/lib/config.ts
|
|
17
|
+
import { readFileSync } from "fs";
|
|
18
|
+
import "zod";
|
|
19
|
+
|
|
20
|
+
// src/lib/atomic.ts
|
|
21
|
+
import { openSync, fsyncSync, closeSync, writeSync, renameSync, mkdtempSync, rmSync } from "fs";
|
|
22
|
+
import { dirname, basename, join } from "path";
|
|
23
|
+
import { tmpdir } from "os";
|
|
24
|
+
import { randomBytes } from "crypto";
|
|
25
|
+
function writeFileAtomic(destPath, content) {
|
|
26
|
+
const dir = dirname(destPath);
|
|
27
|
+
const base = basename(destPath);
|
|
28
|
+
const tmpName = `.${base}.navori.tmp.${randomBytes(6).toString("hex")}`;
|
|
29
|
+
const tmpPath = join(dir, tmpName);
|
|
30
|
+
try {
|
|
31
|
+
const fd = openSync(tmpPath, "w", 420);
|
|
32
|
+
try {
|
|
33
|
+
writeSync(fd, content);
|
|
34
|
+
fsyncSync(fd);
|
|
35
|
+
} finally {
|
|
36
|
+
closeSync(fd);
|
|
37
|
+
}
|
|
38
|
+
renameSync(tmpPath, destPath);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
try {
|
|
41
|
+
rmSync(tmpPath, { force: true });
|
|
42
|
+
} catch {
|
|
43
|
+
}
|
|
44
|
+
throw friendlyFsError(err, destPath);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function friendlyFsError(err, target) {
|
|
48
|
+
if (!(err instanceof Error)) return new Error(String(err));
|
|
49
|
+
const code = err.code;
|
|
50
|
+
switch (code) {
|
|
51
|
+
case "EACCES":
|
|
52
|
+
return new Error(`Cannot write ${target}: permission denied. Check that you own the file and the parent directory is writable.`);
|
|
53
|
+
case "EROFS":
|
|
54
|
+
return new Error(`Cannot write ${target}: filesystem is read-only.`);
|
|
55
|
+
case "EISDIR":
|
|
56
|
+
return new Error(`Cannot write ${target}: a directory exists at that path. Remove it or choose another location.`);
|
|
57
|
+
case "ENOSPC":
|
|
58
|
+
return new Error(`Cannot write ${target}: no space left on device.`);
|
|
59
|
+
case "ENOENT":
|
|
60
|
+
return new Error(`Cannot write ${target}: a directory in the path does not exist.`);
|
|
61
|
+
case "ENAMETOOLONG":
|
|
62
|
+
return new Error(`Cannot write ${target}: path is too long for this filesystem.`);
|
|
63
|
+
default:
|
|
64
|
+
return err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/lib/schema.ts
|
|
69
|
+
import { z } from "zod";
|
|
70
|
+
var ENGINES = ["claude", "agents-md", "cursor", "copilot"];
|
|
71
|
+
var MODELS = ["opus", "sonnet", "haiku"];
|
|
72
|
+
var QualityGateSchema = z.object({
|
|
73
|
+
fast: z.string().min(1),
|
|
74
|
+
full: z.string().min(1)
|
|
75
|
+
});
|
|
76
|
+
var MonorepoWorkspaceSchema = z.object({
|
|
77
|
+
name: z.string().min(1),
|
|
78
|
+
path: z.string().min(1),
|
|
79
|
+
preset: z.string().optional(),
|
|
80
|
+
qualityGate: QualityGateSchema.optional()
|
|
81
|
+
});
|
|
82
|
+
var MonorepoSchema = z.object({
|
|
83
|
+
enabled: z.boolean(),
|
|
84
|
+
tool: z.enum(["pnpm", "turbo", "nx", "rush", "lerna", "npm"]).optional(),
|
|
85
|
+
workspaces: z.array(MonorepoWorkspaceSchema).default([])
|
|
86
|
+
});
|
|
87
|
+
var SddSchema = z.object({
|
|
88
|
+
enabled: z.boolean().default(true),
|
|
89
|
+
specsDir: z.string().default("specs"),
|
|
90
|
+
applyWhen: z.array(z.string()).default([]),
|
|
91
|
+
doesNotApplyTo: z.array(z.string()).default([])
|
|
92
|
+
});
|
|
93
|
+
var HarnessSchema = z.object({
|
|
94
|
+
leader: z.boolean().default(true),
|
|
95
|
+
implementer: z.boolean().default(true),
|
|
96
|
+
reviewer: z.boolean().default(true),
|
|
97
|
+
researcher: z.boolean().default(true),
|
|
98
|
+
ticketAudit: z.boolean().default(true),
|
|
99
|
+
commitPrPilot: z.boolean().default(true),
|
|
100
|
+
explorer: z.boolean().default(true)
|
|
101
|
+
});
|
|
102
|
+
var ModelsSchema = z.object({
|
|
103
|
+
leader: z.enum(MODELS).optional(),
|
|
104
|
+
implementer: z.enum(MODELS).optional(),
|
|
105
|
+
reviewer: z.enum(MODELS).optional(),
|
|
106
|
+
researcher: z.enum(MODELS).optional(),
|
|
107
|
+
ticketAudit: z.enum(MODELS).optional(),
|
|
108
|
+
commitPrPilot: z.enum(MODELS).optional(),
|
|
109
|
+
explorer: z.enum(MODELS).optional()
|
|
110
|
+
});
|
|
111
|
+
var PluginEntrySchema = z.object({
|
|
112
|
+
enabled: z.boolean()
|
|
113
|
+
});
|
|
114
|
+
var AGENT_ROLES_FOR_SCHEMA = [
|
|
115
|
+
"leader",
|
|
116
|
+
"implementer",
|
|
117
|
+
"reviewer",
|
|
118
|
+
"researcher",
|
|
119
|
+
"ticket-audit",
|
|
120
|
+
"commit-pr-pilot",
|
|
121
|
+
"explorer"
|
|
122
|
+
];
|
|
123
|
+
var AgentAssignmentsSchema = z.record(z.string(), z.enum(AGENT_ROLES_FOR_SCHEMA));
|
|
124
|
+
var SkillsSchema = z.object({
|
|
125
|
+
auto: z.array(z.string()).default([]),
|
|
126
|
+
optIn: z.array(z.string()).default([])
|
|
127
|
+
});
|
|
128
|
+
var ProgressSchema = z.object({
|
|
129
|
+
dir: z.string().default("progress"),
|
|
130
|
+
currentFile: z.string().default("current.md"),
|
|
131
|
+
historyFile: z.string().default("history.md"),
|
|
132
|
+
checkpointsDir: z.string().default("progress/checkpoints"),
|
|
133
|
+
archiveAfterDays: z.number().int().positive().default(30)
|
|
134
|
+
});
|
|
135
|
+
var NavoriConfigSchema = z.object({
|
|
136
|
+
$schema: z.string().optional(),
|
|
137
|
+
name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "name must be kebab-case"),
|
|
138
|
+
version: z.string().default("1.0.0"),
|
|
139
|
+
workspace: z.string().optional(),
|
|
140
|
+
engines: z.array(z.enum(ENGINES)).min(1),
|
|
141
|
+
preset: z.string().min(1),
|
|
142
|
+
language: z.enum(["es", "en"]).default("es"),
|
|
143
|
+
branchBase: z.string().default("main"),
|
|
144
|
+
commits: z.enum(["conventional", "conventional-es", "free"]).default("conventional-es"),
|
|
145
|
+
qualityGate: QualityGateSchema.optional(),
|
|
146
|
+
sdd: SddSchema.optional(),
|
|
147
|
+
harness: HarnessSchema.optional(),
|
|
148
|
+
models: ModelsSchema.optional(),
|
|
149
|
+
plugins: z.record(z.string(), PluginEntrySchema).optional(),
|
|
150
|
+
/** Override of which agent owns which skill/managed-block id. Plugins
|
|
151
|
+
* declare their own recommendedAgent; entries here override that. */
|
|
152
|
+
agentAssignments: AgentAssignmentsSchema.optional(),
|
|
153
|
+
skills: SkillsSchema.optional(),
|
|
154
|
+
progress: ProgressSchema.optional(),
|
|
155
|
+
monorepo: MonorepoSchema.optional()
|
|
156
|
+
}).passthrough();
|
|
157
|
+
|
|
158
|
+
// src/lib/config.ts
|
|
159
|
+
var SCHEMA_URL = "https://navori.dev/schema/navori.config.v1.json";
|
|
160
|
+
function writeConfig(path, input) {
|
|
161
|
+
const validated = NavoriConfigSchema.parse({ $schema: SCHEMA_URL, ...input });
|
|
162
|
+
writeFileAtomic(path, JSON.stringify(validated, null, 2) + "\n");
|
|
163
|
+
}
|
|
164
|
+
var ConfigError = class extends Error {
|
|
165
|
+
issues;
|
|
166
|
+
constructor(message, issues) {
|
|
167
|
+
super(message);
|
|
168
|
+
this.name = "ConfigError";
|
|
169
|
+
this.issues = issues;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
function readConfig(path) {
|
|
173
|
+
let raw;
|
|
174
|
+
try {
|
|
175
|
+
raw = readFileSync(path, "utf-8").replace(/^/, "");
|
|
176
|
+
} catch (err) {
|
|
177
|
+
throw new ConfigError(`Cannot read ${path}: ${err.message}`);
|
|
178
|
+
}
|
|
179
|
+
let parsed;
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(raw);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
throw new ConfigError(`Invalid JSON in ${path}: ${err.message}`);
|
|
184
|
+
}
|
|
185
|
+
const result = NavoriConfigSchema.safeParse(parsed);
|
|
186
|
+
if (!result.success) {
|
|
187
|
+
throw new ConfigError(
|
|
188
|
+
`Validation failed for ${path}`,
|
|
189
|
+
result.error.issues
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return result.data;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/lib/detect.ts
|
|
196
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
197
|
+
import { join as join3, basename as basename2 } from "path";
|
|
198
|
+
import { spawnSync } from "child_process";
|
|
199
|
+
|
|
200
|
+
// src/lib/claude-infra.ts
|
|
201
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
202
|
+
import { join as join2 } from "path";
|
|
203
|
+
function safeReaddir(path) {
|
|
204
|
+
try {
|
|
205
|
+
return readdirSync(path);
|
|
206
|
+
} catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function listMarkdownFiles(dir) {
|
|
211
|
+
return safeReaddir(dir).filter((f) => f.endsWith(".md"));
|
|
212
|
+
}
|
|
213
|
+
function listSkillDirs(dir) {
|
|
214
|
+
const result = [];
|
|
215
|
+
for (const entry of safeReaddir(dir)) {
|
|
216
|
+
const full = join2(dir, entry);
|
|
217
|
+
try {
|
|
218
|
+
if (statSync(full).isDirectory()) {
|
|
219
|
+
if (existsSync(join2(full, "SKILL.md"))) result.push(entry);
|
|
220
|
+
} else if (entry.endsWith(".md")) {
|
|
221
|
+
result.push(entry);
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
function countFilesIn(dir) {
|
|
229
|
+
let count = 0;
|
|
230
|
+
for (const entry of safeReaddir(dir)) {
|
|
231
|
+
try {
|
|
232
|
+
if (statSync(join2(dir, entry)).isFile()) count++;
|
|
233
|
+
} catch {
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return count;
|
|
237
|
+
}
|
|
238
|
+
function countSubdirs(dir) {
|
|
239
|
+
let count = 0;
|
|
240
|
+
for (const entry of safeReaddir(dir)) {
|
|
241
|
+
try {
|
|
242
|
+
if (statSync(join2(dir, entry)).isDirectory()) count++;
|
|
243
|
+
} catch {
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return count;
|
|
247
|
+
}
|
|
248
|
+
function detectClaudeInfra(cwd) {
|
|
249
|
+
const claudeDir = join2(cwd, ".claude");
|
|
250
|
+
const agentsDir = join2(claudeDir, "agents");
|
|
251
|
+
const skillsDir = join2(claudeDir, "skills");
|
|
252
|
+
const agentFiles = listMarkdownFiles(agentsDir);
|
|
253
|
+
const skillFiles = listSkillDirs(skillsDir);
|
|
254
|
+
const hasSettings = existsSync(join2(claudeDir, "settings.json"));
|
|
255
|
+
const hasLocalSettings = existsSync(join2(claudeDir, "settings.local.json"));
|
|
256
|
+
const hasClaudeMd = existsSync(join2(cwd, "CLAUDE.md"));
|
|
257
|
+
const hasAgentsMd = existsSync(join2(cwd, "AGENTS.md"));
|
|
258
|
+
const hasCheckpointsMd = existsSync(join2(cwd, "CHECKPOINTS.md"));
|
|
259
|
+
const hasFeatureList = existsSync(join2(cwd, "feature_list.json"));
|
|
260
|
+
const hasNavoriConfig = existsSync(join2(cwd, "navori.config.json"));
|
|
261
|
+
const progressFiles = existsSync(join2(cwd, "progress")) ? countFilesIn(join2(cwd, "progress")) : 0;
|
|
262
|
+
const specsDirs = existsSync(join2(cwd, "specs")) ? countSubdirs(join2(cwd, "specs")) : 0;
|
|
263
|
+
const present = agentFiles.length > 0 || skillFiles.length > 0 || hasSettings || hasLocalSettings || hasClaudeMd || hasAgentsMd || hasCheckpointsMd || hasFeatureList || progressFiles > 0 || specsDirs > 0;
|
|
264
|
+
return {
|
|
265
|
+
present,
|
|
266
|
+
agentFiles,
|
|
267
|
+
skillFiles,
|
|
268
|
+
hasSettings,
|
|
269
|
+
hasLocalSettings,
|
|
270
|
+
hasClaudeMd,
|
|
271
|
+
hasAgentsMd,
|
|
272
|
+
hasCheckpointsMd,
|
|
273
|
+
hasFeatureList,
|
|
274
|
+
progressFiles,
|
|
275
|
+
specsDirs,
|
|
276
|
+
hasNavoriConfig
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/lib/detect.ts
|
|
281
|
+
function detectProject(cwd) {
|
|
282
|
+
const pkg = readPackageJson(cwd);
|
|
283
|
+
const pyproject = readPyproject(cwd);
|
|
284
|
+
const cargo = readCargoToml(cwd);
|
|
285
|
+
const fromPackageJson = typeof pkg?.name === "string" ? pkg.name : null;
|
|
286
|
+
const fromPyproject = typeof pyproject?.name === "string" ? pyproject.name : null;
|
|
287
|
+
const fromCargo = typeof cargo?.name === "string" ? cargo.name : null;
|
|
288
|
+
const fromGit = detectGitRepoName(cwd);
|
|
289
|
+
const fromBasename = basename2(cwd);
|
|
290
|
+
const nameSource = fromPackageJson ? "package.json" : fromPyproject ? "pyproject.toml" : fromCargo ? "Cargo.toml" : fromGit ? "git remote" : "directory name";
|
|
291
|
+
const name = normalizeName(
|
|
292
|
+
fromPackageJson ?? fromPyproject ?? fromCargo ?? fromGit ?? fromBasename
|
|
293
|
+
);
|
|
294
|
+
const branchBase = detectBranchBase(cwd);
|
|
295
|
+
const existingEngines = detectExistingEngines(cwd);
|
|
296
|
+
const packageManager = detectPackageManager(cwd);
|
|
297
|
+
const monorepo = detectMonorepo(cwd);
|
|
298
|
+
const stack = detectStack(cwd, pkg, pyproject, cargo);
|
|
299
|
+
const suggestedPreset = suggestPreset(stack, monorepo);
|
|
300
|
+
const qualityGate = guessQualityGate(pkg, packageManager, stack);
|
|
301
|
+
const claudeInfra = detectClaudeInfra(cwd);
|
|
302
|
+
return {
|
|
303
|
+
name,
|
|
304
|
+
branchBase,
|
|
305
|
+
existingEngines,
|
|
306
|
+
packageManager,
|
|
307
|
+
monorepo,
|
|
308
|
+
stack,
|
|
309
|
+
suggestedPreset,
|
|
310
|
+
qualityGate,
|
|
311
|
+
claudeInfra,
|
|
312
|
+
sources: {
|
|
313
|
+
name: name ? nameSource : null,
|
|
314
|
+
branchBase: branchBase ? "git" : null,
|
|
315
|
+
packageManager: packageManager ? detectPackageManagerSource(cwd) : null
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function normalizeName(raw) {
|
|
320
|
+
if (typeof raw !== "string" || !raw) return null;
|
|
321
|
+
const cleaned = raw.trim().toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
322
|
+
if (!cleaned) return null;
|
|
323
|
+
return /^[a-z0-9]/.test(cleaned) ? cleaned : null;
|
|
324
|
+
}
|
|
325
|
+
function readPackageJson(cwd) {
|
|
326
|
+
const path = join3(cwd, "package.json");
|
|
327
|
+
if (!existsSync2(path)) return null;
|
|
328
|
+
try {
|
|
329
|
+
const raw = readFileSync2(path, "utf-8").replace(/^/, "");
|
|
330
|
+
return JSON.parse(raw);
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function readPyproject(cwd) {
|
|
336
|
+
const path = join3(cwd, "pyproject.toml");
|
|
337
|
+
if (!existsSync2(path)) return null;
|
|
338
|
+
try {
|
|
339
|
+
const content = readFileSync2(path, "utf-8");
|
|
340
|
+
const nameMatch = content.match(/^\s*name\s*=\s*"([^"]+)"/m);
|
|
341
|
+
const deps = [];
|
|
342
|
+
const depBlock = content.match(/\[(?:tool\.poetry\.)?dependencies\]([\s\S]*?)(?:\n\[|$)/);
|
|
343
|
+
if (depBlock?.[1]) {
|
|
344
|
+
for (const line of depBlock[1].split("\n")) {
|
|
345
|
+
const m = line.match(/^\s*([a-zA-Z0-9_\-.]+)\s*=/);
|
|
346
|
+
if (m?.[1] && m[1] !== "python") deps.push(m[1].toLowerCase());
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const projectDeps = content.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
|
|
350
|
+
if (projectDeps?.[1]) {
|
|
351
|
+
const items = projectDeps[1].match(/"([^"]+)"/g) ?? [];
|
|
352
|
+
for (const item of items) {
|
|
353
|
+
const pkgName = item.slice(1, -1).split(/[<>=~!]/)[0]?.trim().toLowerCase();
|
|
354
|
+
if (pkgName) deps.push(pkgName);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { name: nameMatch?.[1] ?? null, deps };
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function readCargoToml(cwd) {
|
|
363
|
+
const path = join3(cwd, "Cargo.toml");
|
|
364
|
+
if (!existsSync2(path)) return null;
|
|
365
|
+
try {
|
|
366
|
+
const content = readFileSync2(path, "utf-8");
|
|
367
|
+
const match = content.match(/^\s*name\s*=\s*"([^"]+)"/m);
|
|
368
|
+
return { name: match?.[1] ?? null };
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function detectGitRepoName(cwd) {
|
|
374
|
+
const url = gitConfigGet(cwd, "remote.origin.url");
|
|
375
|
+
if (!url) return null;
|
|
376
|
+
const match = url.trim().match(/[/:]([^/:]+?)(?:\.git)?$/);
|
|
377
|
+
return match?.[1] ?? null;
|
|
378
|
+
}
|
|
379
|
+
function gitConfigGet(cwd, key) {
|
|
380
|
+
const result = spawnSync("git", ["-C", cwd, "config", "--get", key], {
|
|
381
|
+
encoding: "utf-8",
|
|
382
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
383
|
+
});
|
|
384
|
+
if (result.status !== 0) return null;
|
|
385
|
+
const out = result.stdout.trim();
|
|
386
|
+
return out.length > 0 ? out : null;
|
|
387
|
+
}
|
|
388
|
+
function gitRevParse(cwd, args) {
|
|
389
|
+
const result = spawnSync("git", ["-C", cwd, ...args], {
|
|
390
|
+
encoding: "utf-8",
|
|
391
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
392
|
+
});
|
|
393
|
+
if (result.status !== 0) return null;
|
|
394
|
+
const out = result.stdout.trim();
|
|
395
|
+
return out.length > 0 ? out : null;
|
|
396
|
+
}
|
|
397
|
+
function detectBranchBase(cwd) {
|
|
398
|
+
const originHead = gitRevParse(cwd, [
|
|
399
|
+
"symbolic-ref",
|
|
400
|
+
"--short",
|
|
401
|
+
"refs/remotes/origin/HEAD"
|
|
402
|
+
]);
|
|
403
|
+
if (originHead) return originHead.replace(/^origin\//, "");
|
|
404
|
+
for (const candidate of ["main", "master", "develop", "dev"]) {
|
|
405
|
+
const found = gitRevParse(cwd, ["rev-parse", "--verify", "--quiet", candidate]);
|
|
406
|
+
if (found) return candidate;
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
function detectExistingEngines(cwd) {
|
|
411
|
+
const found = [];
|
|
412
|
+
if (existsSync2(join3(cwd, ".claude"))) found.push("claude");
|
|
413
|
+
if (existsSync2(join3(cwd, "AGENTS.md"))) found.push("agents-md");
|
|
414
|
+
if (existsSync2(join3(cwd, ".cursor"))) found.push("cursor");
|
|
415
|
+
if (existsSync2(join3(cwd, ".github", "copilot-instructions.md"))) found.push("copilot");
|
|
416
|
+
return found;
|
|
417
|
+
}
|
|
418
|
+
function detectPackageManager(cwd) {
|
|
419
|
+
const pkg = readPackageJson(cwd);
|
|
420
|
+
if (pkg?.packageManager) {
|
|
421
|
+
const tool = pkg.packageManager.split("@")[0];
|
|
422
|
+
if (tool === "pnpm" || tool === "npm" || tool === "yarn" || tool === "bun") return tool;
|
|
423
|
+
}
|
|
424
|
+
if (existsSync2(join3(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
425
|
+
if (existsSync2(join3(cwd, "bun.lockb")) || existsSync2(join3(cwd, "bun.lock"))) return "bun";
|
|
426
|
+
if (existsSync2(join3(cwd, "yarn.lock"))) return "yarn";
|
|
427
|
+
if (existsSync2(join3(cwd, "package-lock.json"))) return "npm";
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
function detectPackageManagerSource(cwd) {
|
|
431
|
+
const pkg = readPackageJson(cwd);
|
|
432
|
+
if (pkg?.packageManager) return "package.json";
|
|
433
|
+
if (existsSync2(join3(cwd, "pnpm-lock.yaml"))) return "pnpm-lock.yaml";
|
|
434
|
+
if (existsSync2(join3(cwd, "bun.lockb")) || existsSync2(join3(cwd, "bun.lock"))) return "bun.lock";
|
|
435
|
+
if (existsSync2(join3(cwd, "yarn.lock"))) return "yarn.lock";
|
|
436
|
+
if (existsSync2(join3(cwd, "package-lock.json"))) return "package-lock.json";
|
|
437
|
+
return "unknown";
|
|
438
|
+
}
|
|
439
|
+
function detectMonorepo(cwd) {
|
|
440
|
+
if (existsSync2(join3(cwd, "pnpm-workspace.yaml"))) {
|
|
441
|
+
if (existsSync2(join3(cwd, "turbo.json"))) {
|
|
442
|
+
return { tool: "turbo", source: "turbo.json + pnpm-workspace.yaml" };
|
|
443
|
+
}
|
|
444
|
+
return { tool: "pnpm", source: "pnpm-workspace.yaml" };
|
|
445
|
+
}
|
|
446
|
+
if (existsSync2(join3(cwd, "turbo.json"))) {
|
|
447
|
+
return { tool: "turbo", source: "turbo.json" };
|
|
448
|
+
}
|
|
449
|
+
if (existsSync2(join3(cwd, "nx.json"))) {
|
|
450
|
+
return { tool: "nx", source: "nx.json" };
|
|
451
|
+
}
|
|
452
|
+
if (existsSync2(join3(cwd, "rush.json"))) {
|
|
453
|
+
return { tool: "rush", source: "rush.json" };
|
|
454
|
+
}
|
|
455
|
+
if (existsSync2(join3(cwd, "lerna.json"))) {
|
|
456
|
+
return { tool: "lerna", source: "lerna.json" };
|
|
457
|
+
}
|
|
458
|
+
const pkg = readPackageJson(cwd);
|
|
459
|
+
if (pkg?.workspaces) {
|
|
460
|
+
return { tool: "npm", source: "package.json workspaces" };
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
function collectNodeDeps(pkg) {
|
|
465
|
+
if (!pkg) return [];
|
|
466
|
+
return [
|
|
467
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
468
|
+
...Object.keys(pkg.devDependencies ?? {}),
|
|
469
|
+
...Object.keys(pkg.peerDependencies ?? {})
|
|
470
|
+
];
|
|
471
|
+
}
|
|
472
|
+
function pick(deps, ...candidates) {
|
|
473
|
+
for (const c of candidates) {
|
|
474
|
+
if (deps.has(c)) return c;
|
|
475
|
+
}
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
function detectStack(cwd, pkg, pyproject, cargo) {
|
|
479
|
+
if (pyproject) {
|
|
480
|
+
const deps = new Set(pyproject.deps);
|
|
481
|
+
return {
|
|
482
|
+
language: "python",
|
|
483
|
+
framework: pick(deps, "fastapi", "django", "flask", "starlette") ?? null,
|
|
484
|
+
ui: null,
|
|
485
|
+
forms: pick(deps, "pydantic") ?? null,
|
|
486
|
+
state: null,
|
|
487
|
+
test: pick(deps, "pytest") ?? null,
|
|
488
|
+
deps: Array.from(deps)
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (cargo) {
|
|
492
|
+
return {
|
|
493
|
+
language: "rust",
|
|
494
|
+
framework: null,
|
|
495
|
+
ui: null,
|
|
496
|
+
forms: null,
|
|
497
|
+
state: null,
|
|
498
|
+
test: null,
|
|
499
|
+
deps: []
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const nodeDeps = new Set(collectNodeDeps(pkg));
|
|
503
|
+
if (nodeDeps.size === 0 && !pkg) {
|
|
504
|
+
return {
|
|
505
|
+
language: "unknown",
|
|
506
|
+
framework: null,
|
|
507
|
+
ui: null,
|
|
508
|
+
forms: null,
|
|
509
|
+
state: null,
|
|
510
|
+
test: null,
|
|
511
|
+
deps: []
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
const hasTs = nodeDeps.has("typescript") || existsSync2(join3(cwd, "tsconfig.json"));
|
|
515
|
+
const framework = (
|
|
516
|
+
// Application frameworks (they own the project)
|
|
517
|
+
pick(nodeDeps, "next") ?? pick(nodeDeps, "@nestjs/core") ?? pick(nodeDeps, "@medusajs/medusa") ?? pick(nodeDeps, "@keystone-6/core") ?? pick(nodeDeps, "expo") ?? pick(nodeDeps, "react-native") ?? pick(nodeDeps, "remix") ?? // Meta-frameworks on top of build tools — check before vite/react
|
|
518
|
+
pick(nodeDeps, "astro") ?? pick(nodeDeps, "@sveltejs/kit") ?? pick(nodeDeps, "@builder.io/qwik") ?? pick(nodeDeps, "solid-js") ?? pick(nodeDeps, "@tauri-apps/api") ?? pick(nodeDeps, "electron") ?? pick(nodeDeps, "svelte") ?? pick(nodeDeps, "vue") ?? // Build tools / generic frontend
|
|
519
|
+
pick(nodeDeps, "vite") ?? pick(nodeDeps, "react") ?? // Backend frameworks
|
|
520
|
+
pick(nodeDeps, "@angular/core") ?? pick(nodeDeps, "fastify") ?? pick(nodeDeps, "hono") ?? pick(nodeDeps, "elysia") ?? pick(nodeDeps, "express") ?? null
|
|
521
|
+
);
|
|
522
|
+
const ui = pick(nodeDeps, "@mantine/core") ?? pick(nodeDeps, "@mui/material") ?? pick(nodeDeps, "tailwindcss") ?? pick(nodeDeps, "tamagui") ?? pick(nodeDeps, "@radix-ui/themes") ?? null;
|
|
523
|
+
const forms = pick(nodeDeps, "formik") ?? pick(nodeDeps, "react-hook-form") ?? pick(nodeDeps, "@mantine/form") ?? pick(nodeDeps, "vee-validate") ?? null;
|
|
524
|
+
const state = pick(nodeDeps, "@reduxjs/toolkit") ?? pick(nodeDeps, "redux") ?? pick(nodeDeps, "zustand") ?? pick(nodeDeps, "jotai") ?? pick(nodeDeps, "valtio") ?? pick(nodeDeps, "@tanstack/react-query") ?? pick(nodeDeps, "@apollo/client") ?? null;
|
|
525
|
+
const test = pick(nodeDeps, "vitest") ?? pick(nodeDeps, "jest") ?? pick(nodeDeps, "@playwright/test") ?? pick(nodeDeps, "cypress") ?? null;
|
|
526
|
+
return {
|
|
527
|
+
language: hasTs ? "ts" : pkg ? "js" : "unknown",
|
|
528
|
+
framework,
|
|
529
|
+
ui,
|
|
530
|
+
forms,
|
|
531
|
+
state,
|
|
532
|
+
test,
|
|
533
|
+
deps: Array.from(nodeDeps)
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function suggestPreset(stack, monorepo) {
|
|
537
|
+
if (monorepo) {
|
|
538
|
+
if (monorepo.tool === "turbo") return "monorepo-turbopnpm";
|
|
539
|
+
if (monorepo.tool === "pnpm") return "monorepo-pnpm";
|
|
540
|
+
if (monorepo.tool === "npm" || monorepo.tool === "lerna") return "monorepo-npm";
|
|
541
|
+
}
|
|
542
|
+
if (stack.language === "python") {
|
|
543
|
+
if (stack.framework === "fastapi") return "fastapi-python";
|
|
544
|
+
if (stack.framework === "django") return "django-python";
|
|
545
|
+
return "python";
|
|
546
|
+
}
|
|
547
|
+
if (stack.language === "rust") return "rust";
|
|
548
|
+
const fw = stack.framework;
|
|
549
|
+
const ui = stack.ui;
|
|
550
|
+
const state = stack.state;
|
|
551
|
+
if (fw === "@medusajs/medusa") return "medusa-v2";
|
|
552
|
+
if (fw === "@keystone-6/core") return "bun-keystone";
|
|
553
|
+
if (fw === "next") {
|
|
554
|
+
if (state === "@apollo/client") return "nextjs-apollo";
|
|
555
|
+
return "nextjs";
|
|
556
|
+
}
|
|
557
|
+
if (fw === "@nestjs/core") return "nestjs";
|
|
558
|
+
if (fw === "expo" || fw === "react-native") return "react-native-expo";
|
|
559
|
+
if (fw === "astro") return "astro";
|
|
560
|
+
if (fw === "@sveltejs/kit" || fw === "svelte") return "sveltekit";
|
|
561
|
+
if (fw === "@builder.io/qwik") return "qwik";
|
|
562
|
+
if (fw === "solid-js") return "solid";
|
|
563
|
+
if (fw === "@tauri-apps/api") return "tauri";
|
|
564
|
+
if (fw === "electron") return "electron";
|
|
565
|
+
if (fw === "vue") return "vue";
|
|
566
|
+
if (fw === "@angular/core") return "angular";
|
|
567
|
+
if (fw === "vite") {
|
|
568
|
+
if (ui === "@mantine/core") return "vite-react-ts-mantine";
|
|
569
|
+
return "vite-react-ts";
|
|
570
|
+
}
|
|
571
|
+
if (fw === "react") return "react";
|
|
572
|
+
if (fw === "remix") return "remix";
|
|
573
|
+
if (fw === "fastify") return "fastify";
|
|
574
|
+
if (fw === "hono") return "hono";
|
|
575
|
+
if (fw === "elysia") return "elysia";
|
|
576
|
+
if (fw === "express") return "express-microservice";
|
|
577
|
+
return "custom";
|
|
578
|
+
}
|
|
579
|
+
function guessQualityGate(pkg, pm, stack) {
|
|
580
|
+
if (!pkg) {
|
|
581
|
+
if (stack.language === "python") {
|
|
582
|
+
const full2 = stack.test === "pytest" ? "ruff check . && pytest" : "ruff check .";
|
|
583
|
+
return { fast: "ruff check .", full: full2 };
|
|
584
|
+
}
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
const runner = pm ?? "npm";
|
|
588
|
+
const scripts = pkg.scripts ?? {};
|
|
589
|
+
const has = (name) => typeof scripts[name] === "string";
|
|
590
|
+
if (has("validate")) {
|
|
591
|
+
return { fast: `${runner} run typecheck`, full: `${runner} run validate` };
|
|
592
|
+
}
|
|
593
|
+
if (has("check:all")) {
|
|
594
|
+
return { fast: `${runner} run typecheck`, full: `${runner} run check:all` };
|
|
595
|
+
}
|
|
596
|
+
const fastParts = [];
|
|
597
|
+
if (has("typecheck")) fastParts.push(`${runner} run typecheck`);
|
|
598
|
+
else if (has("type-check")) fastParts.push(`${runner} run type-check`);
|
|
599
|
+
else if (has("check")) fastParts.push(`${runner} run check`);
|
|
600
|
+
else if (has("compile")) fastParts.push(`${runner} run compile`);
|
|
601
|
+
const fullParts = [...fastParts];
|
|
602
|
+
if (has("lint")) fullParts.push(`${runner} run lint`);
|
|
603
|
+
if (has("test:unit")) fullParts.push(`${runner} run test:unit`);
|
|
604
|
+
else if (has("test")) fullParts.push(`${runner} run test`);
|
|
605
|
+
if (fastParts.length === 0 && fullParts.length === 0) return null;
|
|
606
|
+
const fast = fastParts.join(" && ") || `${runner} run lint`;
|
|
607
|
+
const full = fullParts.join(" && ") || fast;
|
|
608
|
+
return { fast, full };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/lib/plugins.ts
|
|
612
|
+
import { readFileSync as readFileSync4, existsSync as existsSync4 } from "fs";
|
|
613
|
+
import { resolve as resolve2, sep } from "path";
|
|
614
|
+
import { z as z3 } from "zod";
|
|
615
|
+
|
|
616
|
+
// src/lib/bundled-assets.ts
|
|
617
|
+
import { fileURLToPath } from "url";
|
|
618
|
+
import { dirname as dirname2, resolve, join as join4 } from "path";
|
|
619
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
620
|
+
var HERE = dirname2(fileURLToPath(import.meta.url));
|
|
621
|
+
var BUNDLED_ASSETS = resolve(HERE, "assets");
|
|
622
|
+
var DEV_PACKAGES = resolve(HERE, "..", "..", "..");
|
|
623
|
+
function isBundled() {
|
|
624
|
+
return existsSync3(resolve(BUNDLED_ASSETS, "core", "package.json"));
|
|
625
|
+
}
|
|
626
|
+
function getCoreRoot() {
|
|
627
|
+
if (isBundled()) return resolve(BUNDLED_ASSETS, "core");
|
|
628
|
+
return resolve(DEV_PACKAGES, "core");
|
|
629
|
+
}
|
|
630
|
+
function getPluginAssetsRoot() {
|
|
631
|
+
if (isBundled()) return resolve(BUNDLED_ASSETS, "plugins");
|
|
632
|
+
return resolve(DEV_PACKAGES, "plugins");
|
|
633
|
+
}
|
|
634
|
+
function getPluginPath(pluginId) {
|
|
635
|
+
return resolve(getPluginAssetsRoot(), pluginId);
|
|
636
|
+
}
|
|
637
|
+
function readBundledCoreVersion() {
|
|
638
|
+
try {
|
|
639
|
+
const pkg = JSON.parse(readFileSync3(resolve(getCoreRoot(), "package.json"), "utf-8"));
|
|
640
|
+
return pkg.version ?? "0.0.0";
|
|
641
|
+
} catch {
|
|
642
|
+
return "0.0.0";
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function bundledPluginManifestPath(pluginId) {
|
|
646
|
+
return resolve(getPluginPath(pluginId), "plugin.json");
|
|
647
|
+
}
|
|
648
|
+
function listBundledPluginIds() {
|
|
649
|
+
const root = getPluginAssetsRoot();
|
|
650
|
+
if (!existsSync3(root)) return [];
|
|
651
|
+
try {
|
|
652
|
+
return readdirSync2(root).filter((entry) => {
|
|
653
|
+
try {
|
|
654
|
+
return statSync2(join4(root, entry)).isDirectory();
|
|
655
|
+
} catch {
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
} catch {
|
|
660
|
+
return [];
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/lib/plugins.ts
|
|
665
|
+
var AGENT_ROLES = [
|
|
666
|
+
"leader",
|
|
667
|
+
"implementer",
|
|
668
|
+
"reviewer",
|
|
669
|
+
"researcher",
|
|
670
|
+
"ticket-audit",
|
|
671
|
+
"commit-pr-pilot",
|
|
672
|
+
"explorer"
|
|
673
|
+
];
|
|
674
|
+
var ManagedEntrySchema = z3.object({
|
|
675
|
+
id: z3.string().min(1),
|
|
676
|
+
file: z3.string().min(1),
|
|
677
|
+
/** Agent that the plugin recommends for this skill/protocol. */
|
|
678
|
+
recommendedAgent: z3.enum(AGENT_ROLES).optional()
|
|
679
|
+
});
|
|
680
|
+
var ExternalToolSchema = z3.object({
|
|
681
|
+
name: z3.string().min(1),
|
|
682
|
+
/** Binary name to look up in PATH. Safer than checkCommand because it
|
|
683
|
+
* never spawns a shell — we walk PATH directories manually. */
|
|
684
|
+
checkBinary: z3.string().regex(/^[a-zA-Z0-9_\-.]+$/, "binary name must be alphanumeric").optional(),
|
|
685
|
+
install: z3.record(z3.string(), z3.string()).optional(),
|
|
686
|
+
postInstall: z3.string().optional()
|
|
687
|
+
});
|
|
688
|
+
var PluginManifestSchema = z3.object({
|
|
689
|
+
id: z3.string().regex(/^[a-z0-9][a-z0-9-]*$/, "plugin id must be kebab-case"),
|
|
690
|
+
name: z3.string(),
|
|
691
|
+
description: z3.string(),
|
|
692
|
+
version: z3.string(),
|
|
693
|
+
managed: z3.array(ManagedEntrySchema).default([]),
|
|
694
|
+
externalTool: ExternalToolSchema.optional()
|
|
695
|
+
});
|
|
696
|
+
var KNOWN_PLUGINS = {
|
|
697
|
+
engram: "@navori/plugin-engram",
|
|
698
|
+
acli: "@navori/plugin-acli",
|
|
699
|
+
gh: "@navori/plugin-gh",
|
|
700
|
+
jscpd: "@navori/plugin-jscpd",
|
|
701
|
+
semgrep: "@navori/plugin-semgrep",
|
|
702
|
+
cognitive: "@navori/plugin-cognitive"
|
|
703
|
+
};
|
|
704
|
+
var PluginNotFoundError = class extends Error {
|
|
705
|
+
pluginId;
|
|
706
|
+
constructor(pluginId) {
|
|
707
|
+
super(`Unknown plugin: '${pluginId}'`);
|
|
708
|
+
this.name = "PluginNotFoundError";
|
|
709
|
+
this.pluginId = pluginId;
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
var PluginManifestError = class extends Error {
|
|
713
|
+
issues;
|
|
714
|
+
constructor(message, issues) {
|
|
715
|
+
super(message);
|
|
716
|
+
this.name = "PluginManifestError";
|
|
717
|
+
this.issues = issues;
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
function listKnownPluginIds() {
|
|
721
|
+
const bundled = listBundledPluginIds();
|
|
722
|
+
return bundled.length > 0 ? bundled : Object.keys(KNOWN_PLUGINS);
|
|
723
|
+
}
|
|
724
|
+
function loadPlugin(pluginId) {
|
|
725
|
+
if (!KNOWN_PLUGINS[pluginId] && !listBundledPluginIds().includes(pluginId)) {
|
|
726
|
+
throw new PluginNotFoundError(pluginId);
|
|
727
|
+
}
|
|
728
|
+
const packageRoot = getPluginPath(pluginId);
|
|
729
|
+
const manifestPath = bundledPluginManifestPath(pluginId);
|
|
730
|
+
if (!existsSync4(manifestPath)) {
|
|
731
|
+
throw new PluginManifestError(`plugin.json not found at ${manifestPath}`);
|
|
732
|
+
}
|
|
733
|
+
let parsed;
|
|
734
|
+
try {
|
|
735
|
+
parsed = JSON.parse(readFileSync4(manifestPath, "utf-8"));
|
|
736
|
+
} catch (err) {
|
|
737
|
+
throw new PluginManifestError(`Invalid JSON in ${manifestPath}: ${err.message}`);
|
|
738
|
+
}
|
|
739
|
+
const result = PluginManifestSchema.safeParse(parsed);
|
|
740
|
+
if (!result.success) {
|
|
741
|
+
throw new PluginManifestError(`Invalid plugin manifest in ${manifestPath}`, result.error.issues);
|
|
742
|
+
}
|
|
743
|
+
const manifest = result.data;
|
|
744
|
+
const rootPrefix = packageRoot.endsWith(sep) ? packageRoot : packageRoot + sep;
|
|
745
|
+
const managedAssets = manifest.managed.map((entry) => {
|
|
746
|
+
const absPath = resolve2(packageRoot, entry.file);
|
|
747
|
+
if (absPath !== packageRoot && !absPath.startsWith(rootPrefix)) {
|
|
748
|
+
throw new PluginManifestError(
|
|
749
|
+
`Plugin '${pluginId}' declared managed.file '${entry.file}' that resolves outside the package root.`
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
return { id: entry.id, absPath };
|
|
753
|
+
});
|
|
754
|
+
return { manifest, packageRoot, managedAssets };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/lib/migrate.ts
|
|
758
|
+
import { mkdirSync, existsSync as existsSync5, copyFileSync, readdirSync as readdirSync3, statSync as statSync3, rmSync as rmSync2 } from "fs";
|
|
759
|
+
import { join as join5, resolve as resolve3, dirname as dirname3 } from "path";
|
|
760
|
+
|
|
761
|
+
// src/lib/home.ts
|
|
762
|
+
import { homedir } from "os";
|
|
763
|
+
import { isAbsolute } from "path";
|
|
764
|
+
function safeHomedir() {
|
|
765
|
+
const home = homedir();
|
|
766
|
+
if (!home || !isAbsolute(home)) {
|
|
767
|
+
throw new Error(
|
|
768
|
+
"Could not determine home directory: HOME env var is empty or not absolute. Set HOME explicitly (e.g. 'HOME=/home/runner') before running navori."
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
return home;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/lib/migrate.ts
|
|
775
|
+
function migrationsRootLazy() {
|
|
776
|
+
return join5(safeHomedir(), ".navori", "migrations");
|
|
777
|
+
}
|
|
778
|
+
function timestamp() {
|
|
779
|
+
const d = /* @__PURE__ */ new Date();
|
|
780
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
781
|
+
return [
|
|
782
|
+
d.getFullYear(),
|
|
783
|
+
"-",
|
|
784
|
+
pad(d.getMonth() + 1),
|
|
785
|
+
"-",
|
|
786
|
+
pad(d.getDate()),
|
|
787
|
+
"T",
|
|
788
|
+
pad(d.getHours()),
|
|
789
|
+
"-",
|
|
790
|
+
pad(d.getMinutes()),
|
|
791
|
+
"-",
|
|
792
|
+
pad(d.getSeconds())
|
|
793
|
+
].join("");
|
|
794
|
+
}
|
|
795
|
+
function copyRecursive(src, dest) {
|
|
796
|
+
const stat = statSync3(src);
|
|
797
|
+
if (stat.isDirectory()) {
|
|
798
|
+
mkdirSync(dest, { recursive: true });
|
|
799
|
+
for (const entry of readdirSync3(src)) {
|
|
800
|
+
copyRecursive(join5(src, entry), join5(dest, entry));
|
|
801
|
+
}
|
|
802
|
+
} else if (stat.isFile()) {
|
|
803
|
+
mkdirSync(dirname3(dest), { recursive: true });
|
|
804
|
+
copyFileSync(src, dest);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
function createMigrationBackup(repoRoot, repoName) {
|
|
808
|
+
const dir = join5(migrationsRootLazy(), timestamp(), repoName);
|
|
809
|
+
mkdirSync(dir, { recursive: true });
|
|
810
|
+
const candidates = [
|
|
811
|
+
".claude",
|
|
812
|
+
"CLAUDE.md",
|
|
813
|
+
"AGENTS.md",
|
|
814
|
+
"CHECKPOINTS.md",
|
|
815
|
+
"feature_list.json",
|
|
816
|
+
"progress",
|
|
817
|
+
"specs"
|
|
818
|
+
];
|
|
819
|
+
const moved = [];
|
|
820
|
+
for (const rel of candidates) {
|
|
821
|
+
const src = resolve3(repoRoot, rel);
|
|
822
|
+
if (!existsSync5(src)) continue;
|
|
823
|
+
const dest = join5(dir, rel);
|
|
824
|
+
copyRecursive(src, dest);
|
|
825
|
+
moved.push(rel);
|
|
826
|
+
}
|
|
827
|
+
return { path: dir, movedPaths: moved };
|
|
828
|
+
}
|
|
829
|
+
function removeOriginals(repoRoot, paths) {
|
|
830
|
+
for (const rel of paths) {
|
|
831
|
+
const target = resolve3(repoRoot, rel);
|
|
832
|
+
if (!existsSync5(target)) continue;
|
|
833
|
+
rmSync2(target, { recursive: true, force: true });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
function migrationsRoot() {
|
|
837
|
+
return migrationsRootLazy();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/lib/workspace.ts
|
|
841
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync4, mkdirSync as mkdirSync2, statSync as statSync4, copyFileSync as copyFileSync2, rmSync as rmSync3 } from "fs";
|
|
842
|
+
import { join as join6 } from "path";
|
|
843
|
+
import { z as z4 } from "zod";
|
|
844
|
+
function workspacesRootLazy() {
|
|
845
|
+
return join6(safeHomedir(), ".navori", "workspaces");
|
|
846
|
+
}
|
|
847
|
+
var MANIFEST_NAME = "workspace.json";
|
|
848
|
+
var RepoEntrySchema = z4.object({
|
|
849
|
+
name: z4.string().regex(/^[a-z0-9][a-z0-9-]*$/, "repo name must be kebab-case"),
|
|
850
|
+
path: z4.string().min(1),
|
|
851
|
+
stack: z4.string().optional(),
|
|
852
|
+
description: z4.string().optional(),
|
|
853
|
+
branchBase: z4.string().optional()
|
|
854
|
+
});
|
|
855
|
+
var WorkspaceDefaultsSchema = z4.object({
|
|
856
|
+
branchBase: z4.string().optional(),
|
|
857
|
+
commits: z4.enum(["conventional", "conventional-es", "free"]).optional(),
|
|
858
|
+
language: z4.enum(["es", "en"]).optional(),
|
|
859
|
+
engines: z4.array(z4.string()).optional(),
|
|
860
|
+
plugins: z4.record(z4.string(), z4.object({ enabled: z4.boolean() })).optional()
|
|
861
|
+
});
|
|
862
|
+
var WorkspaceConfigSchema = z4.object({
|
|
863
|
+
$schema: z4.string().optional(),
|
|
864
|
+
name: z4.string().regex(/^[a-z0-9][a-z0-9-]*$/, "workspace name must be kebab-case"),
|
|
865
|
+
description: z4.string().optional(),
|
|
866
|
+
/** Folder for cross-repo tickets (relative to workspace dir).
|
|
867
|
+
* Restricted to a simple relative segment to prevent path traversal
|
|
868
|
+
* (e.g. "../etc/passwd") and to avoid silent failure of absolute
|
|
869
|
+
* paths under path.join. */
|
|
870
|
+
ticketsDir: z4.string().regex(
|
|
871
|
+
/^[a-zA-Z0-9][a-zA-Z0-9_\-./]*$/,
|
|
872
|
+
"ticketsDir must be a relative path (alphanumeric, '-', '_', '.', '/'). No leading '/' or '..'."
|
|
873
|
+
).refine((s) => !s.split("/").includes(".."), {
|
|
874
|
+
message: "ticketsDir must not contain '..' segments"
|
|
875
|
+
}).default("tickets"),
|
|
876
|
+
defaults: WorkspaceDefaultsSchema.default({}),
|
|
877
|
+
repos: z4.array(RepoEntrySchema).default([])
|
|
878
|
+
});
|
|
879
|
+
var WorkspaceError = class extends Error {
|
|
880
|
+
issues;
|
|
881
|
+
constructor(message, issues) {
|
|
882
|
+
super(message);
|
|
883
|
+
this.name = "WorkspaceError";
|
|
884
|
+
this.issues = issues;
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
function workspaceDirectory(name) {
|
|
888
|
+
return join6(workspacesRootLazy(), name);
|
|
889
|
+
}
|
|
890
|
+
function workspacePath(name) {
|
|
891
|
+
return join6(workspaceDirectory(name), MANIFEST_NAME);
|
|
892
|
+
}
|
|
893
|
+
function legacyWorkspacePath(name) {
|
|
894
|
+
return join6(workspacesRootLazy(), `${name}.json`);
|
|
895
|
+
}
|
|
896
|
+
function ensureWorkspacesRoot() {
|
|
897
|
+
mkdirSync2(workspacesRootLazy(), { recursive: true });
|
|
898
|
+
}
|
|
899
|
+
function migrateLegacyLayoutIfNeeded(name) {
|
|
900
|
+
const legacy = legacyWorkspacePath(name);
|
|
901
|
+
const current = workspacePath(name);
|
|
902
|
+
if (!existsSync6(legacy) || existsSync6(current)) return;
|
|
903
|
+
const dir = workspaceDirectory(name);
|
|
904
|
+
mkdirSync2(dir, { recursive: true });
|
|
905
|
+
copyFileSync2(legacy, current);
|
|
906
|
+
try {
|
|
907
|
+
rmSync3(legacy, { force: true });
|
|
908
|
+
} catch {
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
function listWorkspaces() {
|
|
912
|
+
if (!existsSync6(workspacesRootLazy())) return [];
|
|
913
|
+
for (const entry of readdirSync4(workspacesRootLazy())) {
|
|
914
|
+
if (entry.endsWith(".json")) {
|
|
915
|
+
const name = entry.replace(/\.json$/, "");
|
|
916
|
+
migrateLegacyLayoutIfNeeded(name);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const names = [];
|
|
920
|
+
for (const entry of readdirSync4(workspacesRootLazy())) {
|
|
921
|
+
const full = join6(workspacesRootLazy(), entry);
|
|
922
|
+
try {
|
|
923
|
+
if (!statSync4(full).isDirectory()) continue;
|
|
924
|
+
} catch {
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
if (existsSync6(join6(full, MANIFEST_NAME))) names.push(entry);
|
|
928
|
+
}
|
|
929
|
+
return names.sort();
|
|
930
|
+
}
|
|
931
|
+
function loadWorkspace(name) {
|
|
932
|
+
migrateLegacyLayoutIfNeeded(name);
|
|
933
|
+
const path = workspacePath(name);
|
|
934
|
+
if (!existsSync6(path)) return null;
|
|
935
|
+
let raw;
|
|
936
|
+
try {
|
|
937
|
+
raw = readFileSync5(path, "utf-8").replace(/^/, "");
|
|
938
|
+
} catch (err) {
|
|
939
|
+
throw new WorkspaceError(`Cannot read workspace '${name}': ${err.message}`);
|
|
940
|
+
}
|
|
941
|
+
let parsed;
|
|
942
|
+
try {
|
|
943
|
+
parsed = JSON.parse(raw);
|
|
944
|
+
} catch (err) {
|
|
945
|
+
throw new WorkspaceError(`Invalid JSON in workspace '${name}': ${err.message}`);
|
|
946
|
+
}
|
|
947
|
+
const result = WorkspaceConfigSchema.safeParse(parsed);
|
|
948
|
+
if (!result.success) {
|
|
949
|
+
throw new WorkspaceError(`Validation failed for workspace '${name}'`, result.error.issues);
|
|
950
|
+
}
|
|
951
|
+
return result.data;
|
|
952
|
+
}
|
|
953
|
+
function writeWorkspace(workspace) {
|
|
954
|
+
ensureWorkspacesRoot();
|
|
955
|
+
const dir = workspaceDirectory(workspace.name);
|
|
956
|
+
mkdirSync2(dir, { recursive: true });
|
|
957
|
+
mkdirSync2(join6(dir, workspace.ticketsDir), { recursive: true });
|
|
958
|
+
const path = workspacePath(workspace.name);
|
|
959
|
+
const validated = WorkspaceConfigSchema.parse({
|
|
960
|
+
$schema: "https://navori.dev/schema/navori.workspace.v1.json",
|
|
961
|
+
...workspace
|
|
962
|
+
});
|
|
963
|
+
writeFileAtomic(path, JSON.stringify(validated, null, 2) + "\n");
|
|
964
|
+
return path;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// src/commands/render.ts
|
|
968
|
+
import { defineCommand } from "citty";
|
|
969
|
+
import * as p from "@clack/prompts";
|
|
970
|
+
import { readFileSync as readFileSync7, existsSync as existsSync9 } from "fs";
|
|
971
|
+
import { resolve as resolve6 } from "path";
|
|
972
|
+
|
|
973
|
+
// src/lib/render-plan.ts
|
|
974
|
+
import { readFileSync as readFileSync6, existsSync as existsSync7 } from "fs";
|
|
975
|
+
import { resolve as resolve4 } from "path";
|
|
976
|
+
|
|
977
|
+
// src/lib/marker.ts
|
|
978
|
+
import { createHash } from "crypto";
|
|
979
|
+
var MARKER_OPEN_PREFIX = "<!-- navori:managed";
|
|
980
|
+
var MARKER_CLOSE_PREFIX = "<!-- /navori:managed";
|
|
981
|
+
var MARKER_SUFFIX = "-->";
|
|
982
|
+
function normalize(content) {
|
|
983
|
+
return content.replace(/\r\n?/g, "\n").replace(/\s+$/, "");
|
|
984
|
+
}
|
|
985
|
+
function hashContent(content) {
|
|
986
|
+
return createHash("sha1").update(normalize(content), "utf-8").digest("hex").slice(0, 8);
|
|
987
|
+
}
|
|
988
|
+
function openMarker(id, hash, meta = {}) {
|
|
989
|
+
const parts = [`${MARKER_OPEN_PREFIX} id="${id}"`, `hash="${hash}"`];
|
|
990
|
+
if (meta.version) parts.push(`version="${meta.version}"`);
|
|
991
|
+
if (meta.source) parts.push(`source="${meta.source}"`);
|
|
992
|
+
return parts.join(" ") + ` ${MARKER_SUFFIX}`;
|
|
993
|
+
}
|
|
994
|
+
function closeMarker(id) {
|
|
995
|
+
return `${MARKER_CLOSE_PREFIX} id="${id}" ${MARKER_SUFFIX}`;
|
|
996
|
+
}
|
|
997
|
+
function extractAttr(open, name) {
|
|
998
|
+
const m = open.match(new RegExp(`${name}="([^"]+)"`));
|
|
999
|
+
return m?.[1] ?? null;
|
|
1000
|
+
}
|
|
1001
|
+
function findMarker(existing, id) {
|
|
1002
|
+
const openRegex = new RegExp(
|
|
1003
|
+
`${escapeRegex(MARKER_OPEN_PREFIX)}\\s+id="${escapeRegex(id)}"[^>]*${escapeRegex(MARKER_SUFFIX)}`
|
|
1004
|
+
);
|
|
1005
|
+
const openMatch = openRegex.exec(existing);
|
|
1006
|
+
if (!openMatch) return null;
|
|
1007
|
+
const close = closeMarker(id);
|
|
1008
|
+
const closeStart = existing.indexOf(close, openMatch.index + openMatch[0].length);
|
|
1009
|
+
if (closeStart < 0) return null;
|
|
1010
|
+
const openEnd = openMatch.index + openMatch[0].length;
|
|
1011
|
+
const contentRaw = existing.slice(openEnd, closeStart);
|
|
1012
|
+
const content = normalize(contentRaw).replace(/^\n/, "");
|
|
1013
|
+
return {
|
|
1014
|
+
openStart: openMatch.index,
|
|
1015
|
+
openEnd,
|
|
1016
|
+
closeStart,
|
|
1017
|
+
closeEnd: closeStart + close.length,
|
|
1018
|
+
existingHash: extractAttr(openMatch[0], "hash"),
|
|
1019
|
+
existingVersion: extractAttr(openMatch[0], "version"),
|
|
1020
|
+
existingSource: extractAttr(openMatch[0], "source"),
|
|
1021
|
+
content
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
function escapeRegex(s) {
|
|
1025
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1026
|
+
}
|
|
1027
|
+
function stripOrphanMarkers(existing, id) {
|
|
1028
|
+
const close = closeMarker(id);
|
|
1029
|
+
const openRegex = new RegExp(
|
|
1030
|
+
`${escapeRegex(MARKER_OPEN_PREFIX)}\\s+id="${escapeRegex(id)}"[^>]*${escapeRegex(MARKER_SUFFIX)}`,
|
|
1031
|
+
"g"
|
|
1032
|
+
);
|
|
1033
|
+
const opens = [];
|
|
1034
|
+
for (const m of existing.matchAll(openRegex)) {
|
|
1035
|
+
if (m.index !== void 0) opens.push(m.index);
|
|
1036
|
+
}
|
|
1037
|
+
const closes = [];
|
|
1038
|
+
let from = 0;
|
|
1039
|
+
for (; ; ) {
|
|
1040
|
+
const idx = existing.indexOf(close, from);
|
|
1041
|
+
if (idx < 0) break;
|
|
1042
|
+
closes.push(idx);
|
|
1043
|
+
from = idx + close.length;
|
|
1044
|
+
}
|
|
1045
|
+
let cleaned = existing;
|
|
1046
|
+
let pairedOpens = 0;
|
|
1047
|
+
let pairedCloses = 0;
|
|
1048
|
+
let oi = 0;
|
|
1049
|
+
let ci = 0;
|
|
1050
|
+
while (oi < opens.length && ci < closes.length) {
|
|
1051
|
+
if (closes[ci] > opens[oi]) {
|
|
1052
|
+
pairedOpens++;
|
|
1053
|
+
pairedCloses++;
|
|
1054
|
+
oi++;
|
|
1055
|
+
ci++;
|
|
1056
|
+
} else {
|
|
1057
|
+
ci++;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const orphanOpens = opens.length - pairedOpens;
|
|
1061
|
+
const orphanCloses = closes.length - pairedCloses;
|
|
1062
|
+
if (orphanOpens === 0 && orphanCloses === 0) return existing;
|
|
1063
|
+
const allCloses = [...closes].reverse();
|
|
1064
|
+
for (let i = 0; i < orphanCloses; i++) {
|
|
1065
|
+
const idx = allCloses[i];
|
|
1066
|
+
cleaned = cleaned.slice(0, idx) + cleaned.slice(idx + close.length);
|
|
1067
|
+
}
|
|
1068
|
+
const openMatchesAfter = [...cleaned.matchAll(openRegex)].map((m) => m.index ?? -1).filter((i) => i >= 0);
|
|
1069
|
+
const opensToStrip = openMatchesAfter.slice(-orphanOpens);
|
|
1070
|
+
for (let i = opensToStrip.length - 1; i >= 0; i--) {
|
|
1071
|
+
const idx = opensToStrip[i];
|
|
1072
|
+
openRegex.lastIndex = 0;
|
|
1073
|
+
const matchHere = openRegex.exec(cleaned.slice(idx));
|
|
1074
|
+
const len = matchHere?.[0]?.length ?? 0;
|
|
1075
|
+
if (len > 0) cleaned = cleaned.slice(0, idx) + cleaned.slice(idx + len);
|
|
1076
|
+
}
|
|
1077
|
+
return cleaned.replace(/\n{3,}/g, "\n\n");
|
|
1078
|
+
}
|
|
1079
|
+
function injectManagedSection(existing, id, newContent, meta = {}) {
|
|
1080
|
+
existing = stripOrphanMarkers(existing, id);
|
|
1081
|
+
const newHash = hashContent(newContent);
|
|
1082
|
+
const match = findMarker(existing, id);
|
|
1083
|
+
const canonicalContent = normalize(newContent);
|
|
1084
|
+
if (!match) {
|
|
1085
|
+
const sep2 = existing.length === 0 || existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
1086
|
+
const block2 = `${openMarker(id, newHash, meta)}
|
|
1087
|
+
${canonicalContent}
|
|
1088
|
+
${closeMarker(id)}
|
|
1089
|
+
`;
|
|
1090
|
+
return {
|
|
1091
|
+
output: existing + sep2 + block2,
|
|
1092
|
+
status: "created",
|
|
1093
|
+
details: {
|
|
1094
|
+
existingHash: null,
|
|
1095
|
+
actualHash: newHash,
|
|
1096
|
+
newHash,
|
|
1097
|
+
existingVersion: null,
|
|
1098
|
+
existingSource: null
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
const actualHash = hashContent(match.content);
|
|
1103
|
+
const expectedHash = match.existingHash;
|
|
1104
|
+
const userModified = expectedHash !== null && expectedHash !== actualHash;
|
|
1105
|
+
const versionDrift = match.existingVersion !== null && meta.version !== void 0 && match.existingVersion !== meta.version;
|
|
1106
|
+
const details = {
|
|
1107
|
+
existingHash: expectedHash,
|
|
1108
|
+
actualHash,
|
|
1109
|
+
newHash,
|
|
1110
|
+
existingVersion: match.existingVersion,
|
|
1111
|
+
existingSource: match.existingSource,
|
|
1112
|
+
versionDrift
|
|
1113
|
+
};
|
|
1114
|
+
if (canonicalContent === match.content) {
|
|
1115
|
+
const sameMeta = expectedHash === newHash && match.existingVersion === (meta.version ?? null) && match.existingSource === (meta.source ?? null);
|
|
1116
|
+
if (sameMeta) {
|
|
1117
|
+
return { output: existing, status: "unchanged", details };
|
|
1118
|
+
}
|
|
1119
|
+
const replaced2 = existing.slice(0, match.openStart) + openMarker(id, newHash, meta) + existing.slice(match.openEnd, match.closeEnd);
|
|
1120
|
+
return { output: replaced2, status: "updated", details };
|
|
1121
|
+
}
|
|
1122
|
+
if (userModified) {
|
|
1123
|
+
return { output: existing, status: "user-modified-skipped", details };
|
|
1124
|
+
}
|
|
1125
|
+
const block = `${openMarker(id, newHash, meta)}
|
|
1126
|
+
${canonicalContent}
|
|
1127
|
+
${closeMarker(id)}`;
|
|
1128
|
+
const replaced = existing.slice(0, match.openStart) + block + existing.slice(match.closeEnd);
|
|
1129
|
+
return { output: replaced, status: "updated", details };
|
|
1130
|
+
}
|
|
1131
|
+
function removeManagedSection(existing, id) {
|
|
1132
|
+
const match = findMarker(existing, id);
|
|
1133
|
+
if (!match) return existing;
|
|
1134
|
+
let endCut = match.closeEnd;
|
|
1135
|
+
if (existing[endCut] === "\n") endCut++;
|
|
1136
|
+
return existing.slice(0, match.openStart) + existing.slice(endCut);
|
|
1137
|
+
}
|
|
1138
|
+
function extractManagedContent(existing, id) {
|
|
1139
|
+
const match = findMarker(existing, id);
|
|
1140
|
+
return match ? match.content : null;
|
|
1141
|
+
}
|
|
1142
|
+
function resolveCondition(config, path) {
|
|
1143
|
+
const segments = path.split(".");
|
|
1144
|
+
let cursor = config;
|
|
1145
|
+
for (const seg of segments) {
|
|
1146
|
+
if (cursor === null || cursor === void 0 || typeof cursor !== "object") {
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
cursor = cursor[seg];
|
|
1150
|
+
}
|
|
1151
|
+
return Boolean(cursor);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// src/lib/render-plan.ts
|
|
1155
|
+
var CORE_SOURCE_ID = "@navori/core";
|
|
1156
|
+
var CORE_MANAGED_ASSETS = [
|
|
1157
|
+
{ id: "idioma-rol", relPath: "core-assets/managed/idioma-rol.md", availableLanguages: ["es"] },
|
|
1158
|
+
{ id: "formato-respuesta", relPath: "core-assets/managed/formato-respuesta.md", availableLanguages: ["es"] },
|
|
1159
|
+
{ id: "tipado-fuerte", relPath: "core-assets/managed/tipado-fuerte.md", availableLanguages: ["es"] },
|
|
1160
|
+
{ id: "cierre-sesion", relPath: "core-assets/managed/cierre-sesion.md", availableLanguages: ["es"] }
|
|
1161
|
+
];
|
|
1162
|
+
var CORE_VERSION = readBundledCoreVersion();
|
|
1163
|
+
function resolveAssetPath(asset, language = "es") {
|
|
1164
|
+
const root = getCoreRoot();
|
|
1165
|
+
if (language === "es") {
|
|
1166
|
+
return { path: resolve4(root, asset.relPath), fallback: false };
|
|
1167
|
+
}
|
|
1168
|
+
const langPath = asset.relPath.replace(/^core-assets\/managed\//, `core-assets/managed/${language}/`);
|
|
1169
|
+
const abs = resolve4(root, langPath);
|
|
1170
|
+
if (existsSync7(abs)) return { path: abs, fallback: false };
|
|
1171
|
+
return { path: resolve4(root, asset.relPath), fallback: true };
|
|
1172
|
+
}
|
|
1173
|
+
function interpolateTemplate(content, config) {
|
|
1174
|
+
const configRecord = config;
|
|
1175
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g, (match, path) => {
|
|
1176
|
+
const segments = path.split(".");
|
|
1177
|
+
let cursor = configRecord;
|
|
1178
|
+
for (const seg of segments) {
|
|
1179
|
+
if (cursor === null || cursor === void 0 || typeof cursor !== "object") {
|
|
1180
|
+
cursor = void 0;
|
|
1181
|
+
break;
|
|
1182
|
+
}
|
|
1183
|
+
cursor = cursor[seg];
|
|
1184
|
+
}
|
|
1185
|
+
if (cursor === void 0 || cursor === null) {
|
|
1186
|
+
return `<not configured: ${path}>`;
|
|
1187
|
+
}
|
|
1188
|
+
if (typeof cursor === "string" || typeof cursor === "number" || typeof cursor === "boolean") {
|
|
1189
|
+
return String(cursor);
|
|
1190
|
+
}
|
|
1191
|
+
return match;
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
function computeRenderPlan(existing, config, options = {}) {
|
|
1195
|
+
const skipIds = options.skipIds ?? /* @__PURE__ */ new Set();
|
|
1196
|
+
let working = existing;
|
|
1197
|
+
const entries = [];
|
|
1198
|
+
const languageFallbacks = [];
|
|
1199
|
+
const updatesAvailable = [];
|
|
1200
|
+
const configRecord = config;
|
|
1201
|
+
const language = config.language;
|
|
1202
|
+
for (const asset of CORE_MANAGED_ASSETS) {
|
|
1203
|
+
if (skipIds.has(asset.id)) {
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
if (asset.condition) {
|
|
1207
|
+
const truthy = resolveCondition(configRecord, asset.condition);
|
|
1208
|
+
if (!truthy) {
|
|
1209
|
+
const before = working;
|
|
1210
|
+
working = removeManagedSection(working, asset.id);
|
|
1211
|
+
entries.push({
|
|
1212
|
+
asset,
|
|
1213
|
+
source: "core",
|
|
1214
|
+
status: before === working ? "unchanged" : "removed-condition-false",
|
|
1215
|
+
newContent: null
|
|
1216
|
+
});
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
const resolved = resolveAssetPath(asset, language);
|
|
1221
|
+
if (resolved.fallback) languageFallbacks.push(asset.id);
|
|
1222
|
+
const rawContent = readFileSync6(resolved.path, "utf-8");
|
|
1223
|
+
const content = interpolateTemplate(rawContent, config);
|
|
1224
|
+
const result = injectManagedSection(working, asset.id, content, {
|
|
1225
|
+
source: CORE_SOURCE_ID,
|
|
1226
|
+
version: CORE_VERSION
|
|
1227
|
+
});
|
|
1228
|
+
if (result.details?.versionDrift && result.details.existingVersion) {
|
|
1229
|
+
updatesAvailable.push({
|
|
1230
|
+
id: asset.id,
|
|
1231
|
+
source: CORE_SOURCE_ID,
|
|
1232
|
+
fromVersion: result.details.existingVersion,
|
|
1233
|
+
toVersion: CORE_VERSION
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
entries.push({
|
|
1237
|
+
asset,
|
|
1238
|
+
source: "core",
|
|
1239
|
+
status: result.status,
|
|
1240
|
+
details: result.details,
|
|
1241
|
+
newContent: content
|
|
1242
|
+
});
|
|
1243
|
+
working = result.output;
|
|
1244
|
+
}
|
|
1245
|
+
const missing = [];
|
|
1246
|
+
const declaredEntries = Object.entries(config.plugins ?? {});
|
|
1247
|
+
for (const [declaredId, settings] of declaredEntries) {
|
|
1248
|
+
let plugin;
|
|
1249
|
+
try {
|
|
1250
|
+
plugin = loadPlugin(declaredId);
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
if (err instanceof PluginNotFoundError) {
|
|
1253
|
+
missing.push({ id: declaredId, reason: "unknown plugin id" });
|
|
1254
|
+
} else if (err instanceof PluginManifestError) {
|
|
1255
|
+
missing.push({ id: declaredId, reason: err.message });
|
|
1256
|
+
} else {
|
|
1257
|
+
throw err;
|
|
1258
|
+
}
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
const enabled = settings.enabled === true;
|
|
1262
|
+
if (enabled) {
|
|
1263
|
+
const pluginSource = `@navori/plugin-${plugin.manifest.id}`;
|
|
1264
|
+
for (const entry of plugin.managedAssets) {
|
|
1265
|
+
if (skipIds.has(entry.id)) continue;
|
|
1266
|
+
const rawContent = readFileSync6(entry.absPath, "utf-8");
|
|
1267
|
+
const content = interpolateTemplate(rawContent, config);
|
|
1268
|
+
const result = injectManagedSection(working, entry.id, content, {
|
|
1269
|
+
source: pluginSource,
|
|
1270
|
+
version: plugin.manifest.version
|
|
1271
|
+
});
|
|
1272
|
+
if (result.details?.versionDrift && result.details.existingVersion) {
|
|
1273
|
+
updatesAvailable.push({
|
|
1274
|
+
id: entry.id,
|
|
1275
|
+
source: pluginSource,
|
|
1276
|
+
fromVersion: result.details.existingVersion,
|
|
1277
|
+
toVersion: plugin.manifest.version
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
entries.push({
|
|
1281
|
+
asset: { id: entry.id, relPath: entry.absPath },
|
|
1282
|
+
source: plugin.manifest.id,
|
|
1283
|
+
status: result.status,
|
|
1284
|
+
details: result.details,
|
|
1285
|
+
newContent: content
|
|
1286
|
+
});
|
|
1287
|
+
working = result.output;
|
|
1288
|
+
}
|
|
1289
|
+
} else {
|
|
1290
|
+
for (const entry of plugin.managedAssets) {
|
|
1291
|
+
if (skipIds.has(entry.id)) continue;
|
|
1292
|
+
const before = working;
|
|
1293
|
+
working = removeManagedSection(working, entry.id);
|
|
1294
|
+
entries.push({
|
|
1295
|
+
asset: { id: entry.id, relPath: entry.absPath },
|
|
1296
|
+
source: plugin.manifest.id,
|
|
1297
|
+
status: before === working ? "unchanged" : "removed-condition-false",
|
|
1298
|
+
newContent: null
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
existing,
|
|
1305
|
+
next: working,
|
|
1306
|
+
changed: working !== existing,
|
|
1307
|
+
entries,
|
|
1308
|
+
missingPlugins: missing,
|
|
1309
|
+
languageFallbacks,
|
|
1310
|
+
updatesAvailable
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
function applyPlanWithSkips(existing, config, skipIds) {
|
|
1314
|
+
return computeRenderPlan(existing, config, { skipIds }).next;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// src/lib/backup.ts
|
|
1318
|
+
import { mkdirSync as mkdirSync3, copyFileSync as copyFileSync3, existsSync as existsSync8, readdirSync as readdirSync5, rmSync as rmSync4, statSync as statSync5 } from "fs";
|
|
1319
|
+
import { dirname as dirname4, join as join7, relative, resolve as resolve5 } from "path";
|
|
1320
|
+
function backupRootLazy() {
|
|
1321
|
+
return join7(safeHomedir(), ".navori", "backups");
|
|
1322
|
+
}
|
|
1323
|
+
var DEFAULT_RETENTION_DAYS = 30;
|
|
1324
|
+
function timestamp2() {
|
|
1325
|
+
const d = /* @__PURE__ */ new Date();
|
|
1326
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1327
|
+
return [
|
|
1328
|
+
d.getFullYear(),
|
|
1329
|
+
"-",
|
|
1330
|
+
pad(d.getMonth() + 1),
|
|
1331
|
+
"-",
|
|
1332
|
+
pad(d.getDate()),
|
|
1333
|
+
"T",
|
|
1334
|
+
pad(d.getHours()),
|
|
1335
|
+
"-",
|
|
1336
|
+
pad(d.getMinutes()),
|
|
1337
|
+
"-",
|
|
1338
|
+
pad(d.getSeconds())
|
|
1339
|
+
].join("");
|
|
1340
|
+
}
|
|
1341
|
+
function createBackup(repoRoot, files) {
|
|
1342
|
+
const dir = join7(backupRootLazy(), timestamp2());
|
|
1343
|
+
mkdirSync3(dir, { recursive: true });
|
|
1344
|
+
const copied = [];
|
|
1345
|
+
for (const file of files) {
|
|
1346
|
+
const abs = resolve5(repoRoot, file);
|
|
1347
|
+
if (!existsSync8(abs)) continue;
|
|
1348
|
+
const rel = relative(repoRoot, abs);
|
|
1349
|
+
const dest = join7(dir, rel);
|
|
1350
|
+
mkdirSync3(dirname4(dest), { recursive: true });
|
|
1351
|
+
copyFileSync3(abs, dest);
|
|
1352
|
+
copied.push(rel);
|
|
1353
|
+
}
|
|
1354
|
+
return { path: dir, files: copied };
|
|
1355
|
+
}
|
|
1356
|
+
function purgeOldBackups(retentionDays = DEFAULT_RETENTION_DAYS) {
|
|
1357
|
+
const root = backupRootLazy();
|
|
1358
|
+
if (!existsSync8(root)) return [];
|
|
1359
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1e3;
|
|
1360
|
+
const pruned = [];
|
|
1361
|
+
for (const entry of readdirSync5(root)) {
|
|
1362
|
+
const full = join7(root, entry);
|
|
1363
|
+
const stat = statSync5(full);
|
|
1364
|
+
if (!stat.isDirectory()) continue;
|
|
1365
|
+
if (stat.mtimeMs < cutoff) {
|
|
1366
|
+
rmSync4(full, { recursive: true, force: true });
|
|
1367
|
+
pruned.push(full);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return pruned;
|
|
1371
|
+
}
|
|
1372
|
+
function backupRoot() {
|
|
1373
|
+
return backupRootLazy();
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// src/lib/style.ts
|
|
1377
|
+
import pc from "picocolors";
|
|
1378
|
+
var color = pc;
|
|
1379
|
+
var sym = {
|
|
1380
|
+
created: "+",
|
|
1381
|
+
updated: "~",
|
|
1382
|
+
unchanged: "\xB7",
|
|
1383
|
+
conflict: "!",
|
|
1384
|
+
removed: "-",
|
|
1385
|
+
update: "\u21E1",
|
|
1386
|
+
bullet: "\xB7",
|
|
1387
|
+
ok: "\u2713",
|
|
1388
|
+
fail: "\u2717",
|
|
1389
|
+
empty: "\u25CB",
|
|
1390
|
+
arrow: "\u2192"
|
|
1391
|
+
};
|
|
1392
|
+
function renderStatusSymbol(status) {
|
|
1393
|
+
switch (status) {
|
|
1394
|
+
case "created":
|
|
1395
|
+
return color.green(sym.created);
|
|
1396
|
+
case "updated":
|
|
1397
|
+
return color.yellow(sym.updated);
|
|
1398
|
+
case "unchanged":
|
|
1399
|
+
return color.dim(sym.unchanged);
|
|
1400
|
+
case "user-modified-skipped":
|
|
1401
|
+
return color.red(sym.conflict);
|
|
1402
|
+
case "removed-condition-false":
|
|
1403
|
+
return color.magenta(sym.removed);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
function renderStatusLabel(status) {
|
|
1407
|
+
switch (status) {
|
|
1408
|
+
case "created":
|
|
1409
|
+
return color.green(status);
|
|
1410
|
+
case "updated":
|
|
1411
|
+
return color.yellow(status);
|
|
1412
|
+
case "unchanged":
|
|
1413
|
+
return color.dim(status);
|
|
1414
|
+
case "user-modified-skipped":
|
|
1415
|
+
return color.red(status);
|
|
1416
|
+
case "removed-condition-false":
|
|
1417
|
+
return color.magenta(status);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
function dim(s) {
|
|
1421
|
+
return color.dim(s);
|
|
1422
|
+
}
|
|
1423
|
+
function accent(s) {
|
|
1424
|
+
return color.cyan(s);
|
|
1425
|
+
}
|
|
1426
|
+
function brand(suffix) {
|
|
1427
|
+
const head = color.bold(color.cyan("navori"));
|
|
1428
|
+
return suffix ? `${head} ${color.dim(suffix)}` : head;
|
|
1429
|
+
}
|
|
1430
|
+
function check(ok) {
|
|
1431
|
+
return ok ? color.green(sym.ok) : color.dim(sym.empty);
|
|
1432
|
+
}
|
|
1433
|
+
function kv(rows, opts = {}) {
|
|
1434
|
+
const indent = opts.indent ?? " ";
|
|
1435
|
+
const width = rows.reduce((m, [k]) => Math.max(m, k.length), 0);
|
|
1436
|
+
return rows.map(([k, v]) => `${indent}${color.dim(k.padEnd(width))} ${color.dim(":")} ${v}`).join("\n");
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// src/commands/render.ts
|
|
1440
|
+
function runRender(cwd, dryRun = false) {
|
|
1441
|
+
const configPath = `${cwd}/navori.config.json`;
|
|
1442
|
+
const claudeMdPath = `${cwd}/CLAUDE.md`;
|
|
1443
|
+
if (!existsSync9(configPath)) {
|
|
1444
|
+
return {
|
|
1445
|
+
ok: false,
|
|
1446
|
+
reason: `No navori.config.json at ${configPath}`,
|
|
1447
|
+
filePath: claudeMdPath,
|
|
1448
|
+
entries: [],
|
|
1449
|
+
written: false,
|
|
1450
|
+
languageFallbacks: [],
|
|
1451
|
+
updatesAvailable: [],
|
|
1452
|
+
backupPath: null
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
const config = readConfig(configPath);
|
|
1456
|
+
const existing = existsSync9(claudeMdPath) ? readFileSync7(claudeMdPath, "utf-8") : "";
|
|
1457
|
+
const plan = computeRenderPlan(existing, config);
|
|
1458
|
+
let backupPath = null;
|
|
1459
|
+
if (plan.changed && !dryRun) {
|
|
1460
|
+
if (existsSync9(claudeMdPath)) {
|
|
1461
|
+
const handle = createBackup(cwd, ["CLAUDE.md"]);
|
|
1462
|
+
backupPath = handle.path;
|
|
1463
|
+
purgeOldBackups();
|
|
1464
|
+
}
|
|
1465
|
+
writeFileAtomic(claudeMdPath, plan.next);
|
|
1466
|
+
}
|
|
1467
|
+
return {
|
|
1468
|
+
ok: true,
|
|
1469
|
+
filePath: claudeMdPath,
|
|
1470
|
+
entries: plan.entries,
|
|
1471
|
+
written: plan.changed && !dryRun,
|
|
1472
|
+
languageFallbacks: plan.languageFallbacks,
|
|
1473
|
+
updatesAvailable: plan.updatesAvailable,
|
|
1474
|
+
backupPath
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
var renderCommand = defineCommand({
|
|
1478
|
+
meta: {
|
|
1479
|
+
name: "render",
|
|
1480
|
+
description: "Render managed Core blocks into CLAUDE.md based on navori.config.json"
|
|
1481
|
+
},
|
|
1482
|
+
args: {
|
|
1483
|
+
cwd: { type: "string", description: "Directory to render into (default: cwd)" },
|
|
1484
|
+
"dry-run": { type: "boolean", description: "Show what would change without writing" }
|
|
1485
|
+
},
|
|
1486
|
+
async run({ args }) {
|
|
1487
|
+
const cwd = resolve6(args.cwd ?? process.cwd());
|
|
1488
|
+
p.intro(brand("render"));
|
|
1489
|
+
if (!existsSync9(cwd)) {
|
|
1490
|
+
p.cancel(`Directory not found: ${cwd}`);
|
|
1491
|
+
process.exit(1);
|
|
1492
|
+
}
|
|
1493
|
+
const result = runRender(cwd, Boolean(args["dry-run"]));
|
|
1494
|
+
if (!result.ok) {
|
|
1495
|
+
p.cancel(`${result.reason}. Run 'navori init' first.`);
|
|
1496
|
+
process.exit(1);
|
|
1497
|
+
}
|
|
1498
|
+
reportPlan(result.filePath, result.entries, result.written, Boolean(args["dry-run"]));
|
|
1499
|
+
if (result.languageFallbacks.length > 0) {
|
|
1500
|
+
p.log.warn(
|
|
1501
|
+
`Language fallback to Spanish for: ${result.languageFallbacks.join(", ")} (English version not available yet)`
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
if (result.backupPath) {
|
|
1505
|
+
p.log.message(`${dim("Backup:")} ${result.backupPath}`);
|
|
1506
|
+
}
|
|
1507
|
+
const summary = summarize(result.entries);
|
|
1508
|
+
if (args["dry-run"]) {
|
|
1509
|
+
p.outro(`${dim("Dry-run complete")} ${summary}`);
|
|
1510
|
+
} else if (result.written) {
|
|
1511
|
+
p.outro(`${color.green("Done")} ${summary}`);
|
|
1512
|
+
} else {
|
|
1513
|
+
p.outro(`${dim("Up to date")} ${summary}`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
function summarize(entries) {
|
|
1518
|
+
const counts = entries.reduce((acc, e) => {
|
|
1519
|
+
acc[e.status] = (acc[e.status] ?? 0) + 1;
|
|
1520
|
+
return acc;
|
|
1521
|
+
}, {});
|
|
1522
|
+
const parts = [];
|
|
1523
|
+
if (counts.created) parts.push(color.green(`${counts.created} created`));
|
|
1524
|
+
if (counts.updated) parts.push(color.yellow(`${counts.updated} updated`));
|
|
1525
|
+
if (counts["user-modified-skipped"]) parts.push(color.red(`${counts["user-modified-skipped"]} conflict`));
|
|
1526
|
+
if (counts["removed-condition-false"]) parts.push(color.magenta(`${counts["removed-condition-false"]} removed`));
|
|
1527
|
+
if (counts.unchanged) parts.push(dim(`${counts.unchanged} unchanged`));
|
|
1528
|
+
return parts.length > 0 ? `${dim("\u2014")} ${parts.join(dim(", "))}` : "";
|
|
1529
|
+
}
|
|
1530
|
+
function reportPlan(file, entries, changed, dryRun) {
|
|
1531
|
+
const lines = [file];
|
|
1532
|
+
for (const e of entries) {
|
|
1533
|
+
const sym3 = renderStatusSymbol(e.status);
|
|
1534
|
+
const label = renderStatusLabel(e.status);
|
|
1535
|
+
lines.push(` ${sym3} ${e.asset.id} ${dim("(")}${label}${dim(")")}`);
|
|
1536
|
+
}
|
|
1537
|
+
if (changed && !dryRun) lines.push(` ${dim("\u2192 written")}`);
|
|
1538
|
+
else if (dryRun) lines.push(` ${dim("\u2192 dry-run, no write")}`);
|
|
1539
|
+
else lines.push(` ${dim("\u2192 no changes")}`);
|
|
1540
|
+
p.log.message(lines.join("\n"));
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// src/commands/init-format.ts
|
|
1544
|
+
function formatInfraSummary(infra) {
|
|
1545
|
+
const rows = [];
|
|
1546
|
+
if (infra.agentFiles.length > 0) {
|
|
1547
|
+
rows.push([".claude/agents/", `${infra.agentFiles.join(", ")} ${dim(`(${infra.agentFiles.length})`)}`]);
|
|
1548
|
+
}
|
|
1549
|
+
if (infra.skillFiles.length > 0) {
|
|
1550
|
+
const preview = infra.skillFiles.slice(0, 3).join(", ");
|
|
1551
|
+
const more = infra.skillFiles.length > 3 ? dim(` (+${infra.skillFiles.length - 3} more)`) : "";
|
|
1552
|
+
rows.push([".claude/skills/", `${preview}${more} ${dim(`(${infra.skillFiles.length})`)}`]);
|
|
1553
|
+
}
|
|
1554
|
+
if (infra.hasSettings) rows.push([".claude/settings.json", dim("present")]);
|
|
1555
|
+
if (infra.hasLocalSettings) rows.push([".claude/settings.local.json", dim("present (gitignored)")]);
|
|
1556
|
+
if (infra.hasClaudeMd) rows.push(["CLAUDE.md", dim("present")]);
|
|
1557
|
+
if (infra.hasAgentsMd) rows.push(["AGENTS.md", dim("present")]);
|
|
1558
|
+
if (infra.hasCheckpointsMd) rows.push(["CHECKPOINTS.md", dim("present")]);
|
|
1559
|
+
if (infra.hasFeatureList) rows.push(["feature_list.json", dim("present")]);
|
|
1560
|
+
if (infra.progressFiles > 0) rows.push(["progress/", dim(`${infra.progressFiles} file(s)`)]);
|
|
1561
|
+
if (infra.specsDirs > 0) rows.push(["specs/", dim(`${infra.specsDirs} feature(s)`)]);
|
|
1562
|
+
return kv(rows);
|
|
1563
|
+
}
|
|
1564
|
+
function formatDetectionSummary(d) {
|
|
1565
|
+
const rows = [];
|
|
1566
|
+
rows.push([
|
|
1567
|
+
"name",
|
|
1568
|
+
d.name ? `${color.cyan(d.name)} ${dim(`(from ${d.sources.name})`)}` : dim("(not detected \u2014 will ask)")
|
|
1569
|
+
]);
|
|
1570
|
+
rows.push([
|
|
1571
|
+
"branchBase",
|
|
1572
|
+
d.branchBase ? `${d.branchBase} ${dim(`(from ${d.sources.branchBase})`)}` : `main ${dim("(default \u2014 no git detected)")}`
|
|
1573
|
+
]);
|
|
1574
|
+
rows.push([
|
|
1575
|
+
"engines",
|
|
1576
|
+
d.existingEngines.length > 0 ? `${d.existingEngines.join(", ")} ${dim("(found in repo)")}` : `claude ${dim("(default \u2014 nothing detected)")}`
|
|
1577
|
+
]);
|
|
1578
|
+
if (d.stack.language !== "unknown") rows.push(["stack lang", d.stack.language]);
|
|
1579
|
+
if (d.stack.framework) rows.push(["framework", d.stack.framework]);
|
|
1580
|
+
if (d.stack.ui) rows.push(["ui", d.stack.ui]);
|
|
1581
|
+
if (d.stack.forms) rows.push(["forms", d.stack.forms]);
|
|
1582
|
+
if (d.stack.state) rows.push(["state", d.stack.state]);
|
|
1583
|
+
if (d.stack.test) rows.push(["test", d.stack.test]);
|
|
1584
|
+
if (d.packageManager) {
|
|
1585
|
+
rows.push(["packageManager", `${d.packageManager} ${dim(`(from ${d.sources.packageManager})`)}`]);
|
|
1586
|
+
}
|
|
1587
|
+
if (d.monorepo) {
|
|
1588
|
+
rows.push(["monorepo", `${d.monorepo.tool} ${dim(`(from ${d.monorepo.source})`)}`]);
|
|
1589
|
+
}
|
|
1590
|
+
rows.push(["preset", `${d.suggestedPreset} ${dim("(suggested)")}`]);
|
|
1591
|
+
rows.push(["asset lang", `es ${dim("(default \u2014 change in wizard if you need 'en' fallback)")}`]);
|
|
1592
|
+
if (d.qualityGate) {
|
|
1593
|
+
rows.push(["qualityGate", `${d.qualityGate.full} ${dim("(from package.json scripts)")}`]);
|
|
1594
|
+
}
|
|
1595
|
+
return kv(rows);
|
|
1596
|
+
}
|
|
1597
|
+
function formatWorkspaceSummary(ws) {
|
|
1598
|
+
const d = ws.defaults;
|
|
1599
|
+
const rows = [];
|
|
1600
|
+
if (d.branchBase) rows.push(["branchBase", d.branchBase]);
|
|
1601
|
+
if (d.commits) rows.push(["commits", d.commits]);
|
|
1602
|
+
if (d.language) rows.push(["language", d.language]);
|
|
1603
|
+
if (d.engines && d.engines.length > 0) rows.push(["engines", d.engines.join(", ")]);
|
|
1604
|
+
if (d.plugins && Object.keys(d.plugins).length > 0) {
|
|
1605
|
+
const enabled = Object.entries(d.plugins).filter(([, v]) => v.enabled).map(([k]) => k);
|
|
1606
|
+
rows.push(["plugins", enabled.join(", ") || dim("(none enabled)")]);
|
|
1607
|
+
}
|
|
1608
|
+
if (rows.length === 0) {
|
|
1609
|
+
return ` ${dim("(workspace has no defaults configured)")}`;
|
|
1610
|
+
}
|
|
1611
|
+
return kv(rows);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// src/commands/init.ts
|
|
1615
|
+
var ENGINE_OPTIONS = [
|
|
1616
|
+
{ value: "claude", label: "Claude Code (.claude/)" },
|
|
1617
|
+
{ value: "agents-md", label: "AGENTS.md (universal \u2014 Cursor / Codex / Gemini read it)" },
|
|
1618
|
+
{ value: "cursor", label: "Cursor (.cursor/rules/)" },
|
|
1619
|
+
{ value: "copilot", label: "Copilot (.github/copilot-instructions.md)" }
|
|
1620
|
+
];
|
|
1621
|
+
var initCommand = defineCommand2({
|
|
1622
|
+
meta: {
|
|
1623
|
+
name: "init",
|
|
1624
|
+
description: "Adopt navori in the current repo (auto-detects stack, presets, quality gate)"
|
|
1625
|
+
},
|
|
1626
|
+
args: {
|
|
1627
|
+
yes: {
|
|
1628
|
+
type: "boolean",
|
|
1629
|
+
description: "Accept all detected values + render automatically without prompting"
|
|
1630
|
+
},
|
|
1631
|
+
cwd: {
|
|
1632
|
+
type: "string",
|
|
1633
|
+
description: "Directory to initialize (default: current working directory)"
|
|
1634
|
+
},
|
|
1635
|
+
render: {
|
|
1636
|
+
type: "boolean",
|
|
1637
|
+
default: true,
|
|
1638
|
+
description: "Render CLAUDE.md after writing config. Disable with --no-render."
|
|
1639
|
+
},
|
|
1640
|
+
workspace: {
|
|
1641
|
+
type: "string",
|
|
1642
|
+
description: "Workspace to inherit defaults from (must exist via 'workspace init')"
|
|
1643
|
+
},
|
|
1644
|
+
recommended: {
|
|
1645
|
+
type: "boolean",
|
|
1646
|
+
description: "Opinionated mode: --yes + auto-enable recommended plugins (engram, +gh if GitHub repo)"
|
|
1647
|
+
}
|
|
1648
|
+
},
|
|
1649
|
+
async run({ args }) {
|
|
1650
|
+
const cwd = resolve7(args.cwd ?? process.cwd());
|
|
1651
|
+
const configPath = `${cwd}/navori.config.json`;
|
|
1652
|
+
const autoYes = Boolean(args.yes || args.recommended);
|
|
1653
|
+
p2.intro(brand("init"));
|
|
1654
|
+
if (!existsSync10(cwd)) {
|
|
1655
|
+
p2.cancel(`Directory not found: ${cwd}`);
|
|
1656
|
+
process.exit(1);
|
|
1657
|
+
}
|
|
1658
|
+
if (existsSync10(configPath)) {
|
|
1659
|
+
p2.cancel(`navori.config.json already exists at ${configPath}.`);
|
|
1660
|
+
process.exit(1);
|
|
1661
|
+
}
|
|
1662
|
+
const detected = detectProject(cwd);
|
|
1663
|
+
const mode = await chooseAdoptionMode(cwd, detected.claudeInfra, detected.name, {
|
|
1664
|
+
yes: autoYes
|
|
1665
|
+
});
|
|
1666
|
+
if (mode === null) return cancel3();
|
|
1667
|
+
let workspaceConfig = null;
|
|
1668
|
+
if (args.workspace) {
|
|
1669
|
+
try {
|
|
1670
|
+
workspaceConfig = loadWorkspace(args.workspace);
|
|
1671
|
+
} catch (err) {
|
|
1672
|
+
if (err instanceof WorkspaceError) {
|
|
1673
|
+
p2.cancel(err.message);
|
|
1674
|
+
process.exit(1);
|
|
1675
|
+
}
|
|
1676
|
+
throw err;
|
|
1677
|
+
}
|
|
1678
|
+
if (!workspaceConfig) {
|
|
1679
|
+
p2.cancel(
|
|
1680
|
+
`Workspace '${args.workspace}' not found. Create it with 'navori workspace init ${args.workspace}'.`
|
|
1681
|
+
);
|
|
1682
|
+
process.exit(1);
|
|
1683
|
+
}
|
|
1684
|
+
p2.note(formatWorkspaceSummary(workspaceConfig), `Workspace defaults \xB7 ${workspaceConfig.name}`);
|
|
1685
|
+
}
|
|
1686
|
+
p2.note(formatDetectionSummary(detected), "Detected from this repo");
|
|
1687
|
+
const wsDefaults = workspaceConfig?.defaults;
|
|
1688
|
+
const defaultEngines = wsDefaults?.engines ?? (detected.existingEngines.length > 0 ? detected.existingEngines : ["claude"]);
|
|
1689
|
+
const defaultBranchBase = wsDefaults?.branchBase ?? detected.branchBase ?? "main";
|
|
1690
|
+
const defaultLanguage = wsDefaults?.language ?? "es";
|
|
1691
|
+
const defaultCommits = wsDefaults?.commits;
|
|
1692
|
+
if (autoYes) {
|
|
1693
|
+
if (!detected.name) {
|
|
1694
|
+
p2.cancel("Could not detect project name. Run without --yes/--recommended to provide one.");
|
|
1695
|
+
process.exit(1);
|
|
1696
|
+
}
|
|
1697
|
+
const wsPlugins2 = wsDefaults?.plugins ?? {};
|
|
1698
|
+
const recommendedPlugins = args.recommended ? buildRecommendedPlugins(cwd) : {};
|
|
1699
|
+
const mergedPlugins2 = { ...wsPlugins2, ...recommendedPlugins };
|
|
1700
|
+
if (args.recommended && Object.keys(recommendedPlugins).length > 0) {
|
|
1701
|
+
p2.log.info(
|
|
1702
|
+
`Recommended plugins enabled: ${Object.keys(recommendedPlugins).join(", ")}`
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
writeConfig(configPath, {
|
|
1706
|
+
name: detected.name,
|
|
1707
|
+
...args.workspace ? { workspace: args.workspace } : {},
|
|
1708
|
+
engines: defaultEngines,
|
|
1709
|
+
preset: detected.suggestedPreset,
|
|
1710
|
+
language: defaultLanguage,
|
|
1711
|
+
branchBase: defaultBranchBase,
|
|
1712
|
+
...defaultCommits ? { commits: defaultCommits } : {},
|
|
1713
|
+
...detected.qualityGate ? { qualityGate: detected.qualityGate } : {},
|
|
1714
|
+
...Object.keys(mergedPlugins2).length > 0 ? { plugins: mergedPlugins2 } : {}
|
|
1715
|
+
});
|
|
1716
|
+
p2.log.success(`Wrote ${configPath}`);
|
|
1717
|
+
if (mode === "coexist") {
|
|
1718
|
+
p2.outro("Done \u2014 existing files not touched. Run 'navori render' when ready.");
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
if (args.render !== false) renderInline(cwd);
|
|
1722
|
+
p2.outro("Done");
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
const accept = await p2.confirm({
|
|
1726
|
+
message: detected.name ? `Use these values?` : `Project name could not be detected. Adjust?`,
|
|
1727
|
+
initialValue: Boolean(detected.name)
|
|
1728
|
+
});
|
|
1729
|
+
if (p2.isCancel(accept)) return cancel3();
|
|
1730
|
+
let name = detected.name;
|
|
1731
|
+
let engines = defaultEngines;
|
|
1732
|
+
let branchBase = defaultBranchBase;
|
|
1733
|
+
let workspace = args.workspace;
|
|
1734
|
+
let preset = detected.suggestedPreset;
|
|
1735
|
+
let qualityGate = detected.qualityGate;
|
|
1736
|
+
let language = defaultLanguage;
|
|
1737
|
+
if (!accept || !detected.name) {
|
|
1738
|
+
const toAdjust = await p2.multiselect({
|
|
1739
|
+
message: "What do you want to change?",
|
|
1740
|
+
options: [
|
|
1741
|
+
{ value: "name", label: `Project name${detected.name ? ` (${detected.name})` : " (not detected)"}` },
|
|
1742
|
+
{ value: "language", label: `Language (${defaultLanguage} \u2014 default)` },
|
|
1743
|
+
{ value: "workspace", label: "Workspace" },
|
|
1744
|
+
{ value: "engines", label: `Engines (${defaultEngines.join(", ")})` },
|
|
1745
|
+
{ value: "preset", label: `Preset (${preset})` },
|
|
1746
|
+
{ value: "branchBase", label: `Base branch (${defaultBranchBase})` },
|
|
1747
|
+
{ value: "qualityGate", label: qualityGate ? `Quality gate (${qualityGate.full})` : "Quality gate (not detected)" }
|
|
1748
|
+
],
|
|
1749
|
+
required: !detected.name,
|
|
1750
|
+
initialValues: detected.name ? [] : ["name"]
|
|
1751
|
+
});
|
|
1752
|
+
if (p2.isCancel(toAdjust)) return cancel3();
|
|
1753
|
+
const adjustments = toAdjust;
|
|
1754
|
+
if (adjustments.includes("name") || !name) {
|
|
1755
|
+
const value = await p2.text({
|
|
1756
|
+
message: "Project name (kebab-case)",
|
|
1757
|
+
placeholder: detected.name ?? "my-project",
|
|
1758
|
+
defaultValue: detected.name ?? "",
|
|
1759
|
+
validate(v) {
|
|
1760
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(v)) return "Must be kebab-case (lowercase, hyphens)";
|
|
1761
|
+
return void 0;
|
|
1762
|
+
}
|
|
1763
|
+
});
|
|
1764
|
+
if (p2.isCancel(value)) return cancel3();
|
|
1765
|
+
name = value;
|
|
1766
|
+
}
|
|
1767
|
+
if (adjustments.includes("language")) {
|
|
1768
|
+
const value = await p2.select({
|
|
1769
|
+
message: "Language for managed Core assets",
|
|
1770
|
+
options: [
|
|
1771
|
+
{ value: "es", label: "Espa\xF1ol (default \u2014 full coverage)" },
|
|
1772
|
+
{ value: "en", label: "English (limited \u2014 falls back to es if asset not localized)" }
|
|
1773
|
+
],
|
|
1774
|
+
initialValue: defaultLanguage
|
|
1775
|
+
});
|
|
1776
|
+
if (p2.isCancel(value)) return cancel3();
|
|
1777
|
+
language = value;
|
|
1778
|
+
}
|
|
1779
|
+
if (adjustments.includes("workspace")) {
|
|
1780
|
+
const value = await p2.text({
|
|
1781
|
+
message: "Workspace (optional, e.g. bonum, navori)",
|
|
1782
|
+
placeholder: "leave empty for none"
|
|
1783
|
+
});
|
|
1784
|
+
if (p2.isCancel(value)) return cancel3();
|
|
1785
|
+
const trimmed = value.trim();
|
|
1786
|
+
if (trimmed) workspace = trimmed;
|
|
1787
|
+
}
|
|
1788
|
+
if (adjustments.includes("engines")) {
|
|
1789
|
+
const value = await p2.multiselect({
|
|
1790
|
+
message: "Engines to target",
|
|
1791
|
+
options: ENGINE_OPTIONS,
|
|
1792
|
+
required: true,
|
|
1793
|
+
initialValues: defaultEngines
|
|
1794
|
+
});
|
|
1795
|
+
if (p2.isCancel(value)) return cancel3();
|
|
1796
|
+
engines = value;
|
|
1797
|
+
}
|
|
1798
|
+
if (adjustments.includes("preset")) {
|
|
1799
|
+
const value = await p2.text({
|
|
1800
|
+
message: "Stack preset (free text for v1)",
|
|
1801
|
+
placeholder: preset,
|
|
1802
|
+
defaultValue: preset
|
|
1803
|
+
});
|
|
1804
|
+
if (p2.isCancel(value)) return cancel3();
|
|
1805
|
+
preset = value;
|
|
1806
|
+
}
|
|
1807
|
+
if (adjustments.includes("branchBase")) {
|
|
1808
|
+
const value = await p2.text({
|
|
1809
|
+
message: "Base branch",
|
|
1810
|
+
placeholder: defaultBranchBase,
|
|
1811
|
+
defaultValue: defaultBranchBase
|
|
1812
|
+
});
|
|
1813
|
+
if (p2.isCancel(value)) return cancel3();
|
|
1814
|
+
branchBase = value;
|
|
1815
|
+
}
|
|
1816
|
+
if (adjustments.includes("qualityGate")) {
|
|
1817
|
+
const fastVal = await p2.text({
|
|
1818
|
+
message: "Quality gate (fast \u2014 runs on Stop hook)",
|
|
1819
|
+
placeholder: qualityGate?.fast ?? "pnpm tsc --noEmit",
|
|
1820
|
+
defaultValue: qualityGate?.fast ?? ""
|
|
1821
|
+
});
|
|
1822
|
+
if (p2.isCancel(fastVal)) return cancel3();
|
|
1823
|
+
const fullVal = await p2.text({
|
|
1824
|
+
message: "Quality gate (full \u2014 runs before close session)",
|
|
1825
|
+
placeholder: qualityGate?.full ?? fastVal,
|
|
1826
|
+
defaultValue: qualityGate?.full ?? fastVal
|
|
1827
|
+
});
|
|
1828
|
+
if (p2.isCancel(fullVal)) return cancel3();
|
|
1829
|
+
if (fastVal.trim() && fullVal.trim()) {
|
|
1830
|
+
qualityGate = { fast: fastVal.trim(), full: fullVal.trim() };
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
if (!name) {
|
|
1835
|
+
p2.cancel("Project name is required");
|
|
1836
|
+
process.exit(1);
|
|
1837
|
+
}
|
|
1838
|
+
const pluginsToEnable = await pickPlugins();
|
|
1839
|
+
if (pluginsToEnable === null) return cancel3();
|
|
1840
|
+
const pluginsConfig = pluginsToEnable.reduce(
|
|
1841
|
+
(acc, id) => {
|
|
1842
|
+
acc[id] = { enabled: true };
|
|
1843
|
+
return acc;
|
|
1844
|
+
},
|
|
1845
|
+
{}
|
|
1846
|
+
);
|
|
1847
|
+
const agentAssignments = await pickAgentAssignments(pluginsToEnable);
|
|
1848
|
+
if (agentAssignments === null) return cancel3();
|
|
1849
|
+
const wsPlugins = wsDefaults?.plugins ?? {};
|
|
1850
|
+
const mergedPlugins = { ...wsPlugins, ...pluginsConfig };
|
|
1851
|
+
writeConfig(configPath, {
|
|
1852
|
+
name,
|
|
1853
|
+
...workspace ? { workspace } : {},
|
|
1854
|
+
engines,
|
|
1855
|
+
preset,
|
|
1856
|
+
language,
|
|
1857
|
+
branchBase,
|
|
1858
|
+
...defaultCommits ? { commits: defaultCommits } : {},
|
|
1859
|
+
...qualityGate ? { qualityGate } : {},
|
|
1860
|
+
...Object.keys(mergedPlugins).length > 0 ? { plugins: mergedPlugins } : {},
|
|
1861
|
+
...Object.keys(agentAssignments).length > 0 ? { agentAssignments } : {}
|
|
1862
|
+
});
|
|
1863
|
+
p2.log.success(`Wrote ${configPath}`);
|
|
1864
|
+
if (mode === "coexist") {
|
|
1865
|
+
p2.outro("Done \u2014 existing files not touched. Run 'navori render' when ready.");
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
if (args.render === false) {
|
|
1869
|
+
p2.outro("Done (skipped render)");
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
const shouldRender = await p2.confirm({
|
|
1873
|
+
message: "Render CLAUDE.md now?",
|
|
1874
|
+
initialValue: true
|
|
1875
|
+
});
|
|
1876
|
+
if (p2.isCancel(shouldRender) || !shouldRender) {
|
|
1877
|
+
p2.outro("Done (run 'navori render' when ready)");
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
renderInline(cwd);
|
|
1881
|
+
p2.outro("Your harness is ready");
|
|
1882
|
+
}
|
|
1883
|
+
});
|
|
1884
|
+
async function chooseAdoptionMode(cwd, infra, projectName, args) {
|
|
1885
|
+
if (!infra.present) return "fresh";
|
|
1886
|
+
if (args.yes) {
|
|
1887
|
+
p2.log.warn("Existing Claude infrastructure detected \u2014 using 'coexist' mode (safe)");
|
|
1888
|
+
return "coexist";
|
|
1889
|
+
}
|
|
1890
|
+
p2.log.warn("Existing Claude infrastructure detected:");
|
|
1891
|
+
p2.note(formatInfraSummary(infra), "Files found");
|
|
1892
|
+
const choice = await p2.select({
|
|
1893
|
+
message: "How do you want to adopt navori?",
|
|
1894
|
+
options: [
|
|
1895
|
+
{
|
|
1896
|
+
value: "coexist",
|
|
1897
|
+
label: "Coexist (recommended)",
|
|
1898
|
+
hint: "add what's missing, never modify existing files"
|
|
1899
|
+
},
|
|
1900
|
+
{
|
|
1901
|
+
value: "replace",
|
|
1902
|
+
label: "Replace",
|
|
1903
|
+
hint: "backup everything to ~/.navori/migrations/<ts>/ and start fresh"
|
|
1904
|
+
}
|
|
1905
|
+
],
|
|
1906
|
+
initialValue: "coexist"
|
|
1907
|
+
});
|
|
1908
|
+
if (p2.isCancel(choice)) return null;
|
|
1909
|
+
if (choice === "replace") {
|
|
1910
|
+
const confirm10 = await p2.confirm({
|
|
1911
|
+
message: `This will move .claude/, CLAUDE.md, AGENTS.md, CHECKPOINTS.md, feature_list.json, progress/, specs/ to ~/.navori/migrations/. Continue?`,
|
|
1912
|
+
initialValue: false
|
|
1913
|
+
});
|
|
1914
|
+
if (p2.isCancel(confirm10) || !confirm10) return null;
|
|
1915
|
+
const backup = createMigrationBackup(cwd, projectName ?? "unknown");
|
|
1916
|
+
p2.log.success(`Backed up ${backup.movedPaths.length} item(s) to ${backup.path}`);
|
|
1917
|
+
removeOriginals(cwd, backup.movedPaths);
|
|
1918
|
+
p2.log.info(`Removed originals from ${cwd}`);
|
|
1919
|
+
return "replace";
|
|
1920
|
+
}
|
|
1921
|
+
return "coexist";
|
|
1922
|
+
}
|
|
1923
|
+
async function pickPlugins() {
|
|
1924
|
+
const ids = listKnownPluginIds();
|
|
1925
|
+
if (ids.length === 0) return [];
|
|
1926
|
+
const options = ids.map((id) => {
|
|
1927
|
+
const plugin = (() => {
|
|
1928
|
+
try {
|
|
1929
|
+
return loadPlugin(id);
|
|
1930
|
+
} catch {
|
|
1931
|
+
return null;
|
|
1932
|
+
}
|
|
1933
|
+
})();
|
|
1934
|
+
return {
|
|
1935
|
+
value: id,
|
|
1936
|
+
label: plugin ? `${plugin.manifest.name} (${id})` : id,
|
|
1937
|
+
hint: plugin?.manifest.description
|
|
1938
|
+
};
|
|
1939
|
+
});
|
|
1940
|
+
const selected = await p2.multiselect({
|
|
1941
|
+
message: "Plugins to enable",
|
|
1942
|
+
options,
|
|
1943
|
+
required: false,
|
|
1944
|
+
initialValues: []
|
|
1945
|
+
});
|
|
1946
|
+
if (p2.isCancel(selected)) return null;
|
|
1947
|
+
return selected;
|
|
1948
|
+
}
|
|
1949
|
+
async function pickAgentAssignments(enabledPlugins) {
|
|
1950
|
+
if (enabledPlugins.length === 0) return {};
|
|
1951
|
+
const recommendations = [];
|
|
1952
|
+
for (const pluginId of enabledPlugins) {
|
|
1953
|
+
let plugin;
|
|
1954
|
+
try {
|
|
1955
|
+
plugin = loadPlugin(pluginId);
|
|
1956
|
+
} catch {
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1959
|
+
for (const entry of plugin.manifest.managed) {
|
|
1960
|
+
if (entry.recommendedAgent) {
|
|
1961
|
+
recommendations.push({
|
|
1962
|
+
id: entry.id,
|
|
1963
|
+
pluginId,
|
|
1964
|
+
recommendedAgent: entry.recommendedAgent
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
if (recommendations.length === 0) return {};
|
|
1970
|
+
const summary = recommendations.map((r) => ` \xB7 ${r.id} (${r.pluginId}) \u2192 ${r.recommendedAgent}`).join("\n");
|
|
1971
|
+
p2.log.message(`Recommended skill \u2192 agent assignments:
|
|
1972
|
+
${summary}`);
|
|
1973
|
+
const accept = await p2.confirm({
|
|
1974
|
+
message: "Use these assignments?",
|
|
1975
|
+
initialValue: true
|
|
1976
|
+
});
|
|
1977
|
+
if (p2.isCancel(accept)) return null;
|
|
1978
|
+
if (accept) return {};
|
|
1979
|
+
const overrides = {};
|
|
1980
|
+
const agentOptions = [
|
|
1981
|
+
{ value: "leader", label: "leader (orquestador)" },
|
|
1982
|
+
{ value: "implementer", label: "implementer (escribe c\xF3digo)" },
|
|
1983
|
+
{ value: "reviewer", label: "reviewer (revisa diff)" },
|
|
1984
|
+
{ value: "researcher", label: "researcher (lee, no escribe)" },
|
|
1985
|
+
{ value: "ticket-audit", label: "ticket-audit (an\xE1lisis profundo)" },
|
|
1986
|
+
{ value: "commit-pr-pilot", label: "commit-pr-pilot (commits + PRs)" },
|
|
1987
|
+
{ value: "explorer", label: "explorer (exploraci\xF3n inicial)" }
|
|
1988
|
+
];
|
|
1989
|
+
for (const rec of recommendations) {
|
|
1990
|
+
const choice = await p2.select({
|
|
1991
|
+
message: `Agent for '${rec.id}' (${rec.pluginId})`,
|
|
1992
|
+
options: agentOptions,
|
|
1993
|
+
initialValue: rec.recommendedAgent
|
|
1994
|
+
});
|
|
1995
|
+
if (p2.isCancel(choice)) return null;
|
|
1996
|
+
if (choice !== rec.recommendedAgent) {
|
|
1997
|
+
overrides[rec.id] = choice;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return overrides;
|
|
2001
|
+
}
|
|
2002
|
+
function buildRecommendedPlugins(cwd) {
|
|
2003
|
+
const result = {
|
|
2004
|
+
engram: { enabled: true }
|
|
2005
|
+
};
|
|
2006
|
+
if (isGitHubRepo(cwd)) {
|
|
2007
|
+
result.gh = { enabled: true };
|
|
2008
|
+
}
|
|
2009
|
+
return result;
|
|
2010
|
+
}
|
|
2011
|
+
function isGitHubRepo(cwd) {
|
|
2012
|
+
const r = spawnSync2("git", ["-C", cwd, "config", "--get", "remote.origin.url"], {
|
|
2013
|
+
encoding: "utf-8",
|
|
2014
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
2015
|
+
});
|
|
2016
|
+
if (r.status !== 0) return false;
|
|
2017
|
+
return /github\.com/i.test(r.stdout);
|
|
2018
|
+
}
|
|
2019
|
+
function renderInline(cwd) {
|
|
2020
|
+
const result = runRender(cwd, false);
|
|
2021
|
+
if (!result.ok) {
|
|
2022
|
+
p2.log.error(result.reason ?? "Render failed");
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
const counts = result.entries.reduce((acc, e) => {
|
|
2026
|
+
acc[e.status] = (acc[e.status] ?? 0) + 1;
|
|
2027
|
+
return acc;
|
|
2028
|
+
}, {});
|
|
2029
|
+
const parts = [];
|
|
2030
|
+
if (counts.created) parts.push(color.green(`${counts.created} created`));
|
|
2031
|
+
if (counts.updated) parts.push(color.yellow(`${counts.updated} updated`));
|
|
2032
|
+
if (counts["user-modified-skipped"]) parts.push(color.red(`${counts["user-modified-skipped"]} conflict`));
|
|
2033
|
+
if (counts["removed-condition-false"]) parts.push(color.magenta(`${counts["removed-condition-false"]} removed`));
|
|
2034
|
+
if (counts.unchanged) parts.push(dim(`${counts.unchanged} unchanged`));
|
|
2035
|
+
const summary = parts.length > 0 ? ` ${dim("\u2014")} ${parts.join(dim(", "))}` : "";
|
|
2036
|
+
if (result.written) {
|
|
2037
|
+
p2.log.success(`Rendered ${result.filePath}${summary}`);
|
|
2038
|
+
} else {
|
|
2039
|
+
p2.log.info(`No render needed${summary}`);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
function cancel3() {
|
|
2043
|
+
p2.cancel("Cancelled");
|
|
2044
|
+
process.exit(0);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// src/commands/doctor.ts
|
|
2048
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
2049
|
+
import * as p3 from "@clack/prompts";
|
|
2050
|
+
import { existsSync as existsSync11, readFileSync as readFileSync8 } from "fs";
|
|
2051
|
+
import { resolve as resolve8 } from "path";
|
|
2052
|
+
function listMarkers(filePath) {
|
|
2053
|
+
if (!existsSync11(filePath)) return [];
|
|
2054
|
+
const content = readFileSync8(filePath, "utf-8");
|
|
2055
|
+
const re = /<!-- navori:managed [^>]*-->/g;
|
|
2056
|
+
const result = [];
|
|
2057
|
+
for (const match of content.matchAll(re)) {
|
|
2058
|
+
const tag = match[0];
|
|
2059
|
+
if (tag.startsWith("<!-- /navori:managed")) continue;
|
|
2060
|
+
const id = tag.match(/id="([^"]+)"/)?.[1] ?? "?";
|
|
2061
|
+
const hash = tag.match(/hash="([^"]+)"/)?.[1] ?? null;
|
|
2062
|
+
const version = tag.match(/version="([^"]+)"/)?.[1] ?? null;
|
|
2063
|
+
const source = tag.match(/source="([^"]+)"/)?.[1] ?? null;
|
|
2064
|
+
result.push({ id, hash, version, source });
|
|
2065
|
+
}
|
|
2066
|
+
return result;
|
|
2067
|
+
}
|
|
2068
|
+
var doctorCommand = defineCommand3({
|
|
2069
|
+
meta: {
|
|
2070
|
+
name: "doctor",
|
|
2071
|
+
description: "Inspect navori.config.json and report resolved state + managed blocks"
|
|
2072
|
+
},
|
|
2073
|
+
args: {
|
|
2074
|
+
cwd: { type: "string", description: "Directory to inspect (default: cwd)" },
|
|
2075
|
+
json: { type: "boolean", description: "Output as JSON (pipeable)" }
|
|
2076
|
+
},
|
|
2077
|
+
async run({ args }) {
|
|
2078
|
+
const cwd = resolve8(args.cwd ?? process.cwd());
|
|
2079
|
+
const configPath = `${cwd}/navori.config.json`;
|
|
2080
|
+
const claudeMdPath = `${cwd}/CLAUDE.md`;
|
|
2081
|
+
if (!args.json) p3.intro(brand("doctor"));
|
|
2082
|
+
if (!existsSync11(cwd)) {
|
|
2083
|
+
if (args.json) {
|
|
2084
|
+
console.log(JSON.stringify({ ok: false, error: "directory-missing", cwd }));
|
|
2085
|
+
} else {
|
|
2086
|
+
p3.cancel(`Directory not found: ${cwd}`);
|
|
2087
|
+
}
|
|
2088
|
+
process.exit(1);
|
|
2089
|
+
}
|
|
2090
|
+
if (!existsSync11(configPath)) {
|
|
2091
|
+
if (args.json) {
|
|
2092
|
+
console.log(JSON.stringify({ ok: false, error: "config-missing", configPath }));
|
|
2093
|
+
} else {
|
|
2094
|
+
p3.cancel(`No navori.config.json at ${configPath}. Run 'navori init' first.`);
|
|
2095
|
+
}
|
|
2096
|
+
process.exit(1);
|
|
2097
|
+
}
|
|
2098
|
+
let config;
|
|
2099
|
+
try {
|
|
2100
|
+
config = readConfig(configPath);
|
|
2101
|
+
} catch (err) {
|
|
2102
|
+
if (err instanceof ConfigError) {
|
|
2103
|
+
if (args.json) {
|
|
2104
|
+
console.log(JSON.stringify({ ok: false, error: "config-invalid", message: err.message, issues: err.issues }));
|
|
2105
|
+
} else {
|
|
2106
|
+
p3.cancel(err.message);
|
|
2107
|
+
if (err.issues) {
|
|
2108
|
+
for (const issue of err.issues) {
|
|
2109
|
+
console.error(` - ${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
process.exit(1);
|
|
2114
|
+
}
|
|
2115
|
+
throw err;
|
|
2116
|
+
}
|
|
2117
|
+
const markers = listMarkers(claudeMdPath);
|
|
2118
|
+
const missingPlugins = collectMissingPlugins(config);
|
|
2119
|
+
const report = {
|
|
2120
|
+
ok: missingPlugins.length === 0,
|
|
2121
|
+
configPath,
|
|
2122
|
+
config,
|
|
2123
|
+
checks: {
|
|
2124
|
+
claudeMdExists: existsSync11(claudeMdPath),
|
|
2125
|
+
agentsMdExists: existsSync11(`${cwd}/AGENTS.md`),
|
|
2126
|
+
claudeDirExists: existsSync11(`${cwd}/.claude`),
|
|
2127
|
+
progressDirExists: existsSync11(`${cwd}/${config.progress?.dir ?? "progress"}`)
|
|
2128
|
+
},
|
|
2129
|
+
managedBlocks: markers,
|
|
2130
|
+
missingPlugins
|
|
2131
|
+
};
|
|
2132
|
+
if (args.json) {
|
|
2133
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
p3.note(
|
|
2137
|
+
kv([
|
|
2138
|
+
["name", accent(config.name)],
|
|
2139
|
+
["version", config.version],
|
|
2140
|
+
["workspace", config.workspace ?? dim("(none)")],
|
|
2141
|
+
["engines", config.engines.join(", ")],
|
|
2142
|
+
["preset", config.preset],
|
|
2143
|
+
["language", config.language],
|
|
2144
|
+
["branchBase", config.branchBase],
|
|
2145
|
+
["commits", config.commits]
|
|
2146
|
+
]),
|
|
2147
|
+
`Config \xB7 ${dim(configPath)}`
|
|
2148
|
+
);
|
|
2149
|
+
p3.note(
|
|
2150
|
+
[
|
|
2151
|
+
` ${check(report.checks.claudeMdExists)} CLAUDE.md`,
|
|
2152
|
+
` ${check(report.checks.agentsMdExists)} AGENTS.md`,
|
|
2153
|
+
` ${check(report.checks.claudeDirExists)} .claude/`,
|
|
2154
|
+
` ${check(report.checks.progressDirExists)} ${config.progress?.dir ?? "progress"}/`
|
|
2155
|
+
].join("\n"),
|
|
2156
|
+
"Filesystem checks"
|
|
2157
|
+
);
|
|
2158
|
+
if (markers.length > 0) {
|
|
2159
|
+
const lines = markers.map((m) => {
|
|
2160
|
+
const ver = m.version ? dim(` v${m.version}`) : dim(" (no version)");
|
|
2161
|
+
const src = m.source ?? dim("(unknown source)");
|
|
2162
|
+
return ` ${color.cyan(sym.bullet)} ${accent(m.id)} ${dim("\u2190")} ${src}${ver}`;
|
|
2163
|
+
});
|
|
2164
|
+
p3.note(lines.join("\n"), `Managed blocks in CLAUDE.md \xB7 ${markers.length}`);
|
|
2165
|
+
}
|
|
2166
|
+
const assignments = collectAssignments(config);
|
|
2167
|
+
if (assignments.length > 0) {
|
|
2168
|
+
const lines = assignments.map((a) => {
|
|
2169
|
+
const override = a.override ? ` ${dim("(overridden)")}` : "";
|
|
2170
|
+
return ` ${color.cyan(sym.bullet)} ${accent(a.id)} ${dim("\u2192")} ${a.agent}${override}`;
|
|
2171
|
+
});
|
|
2172
|
+
p3.note(lines.join("\n"), `Skill \u2192 agent assignments \xB7 ${assignments.length}`);
|
|
2173
|
+
}
|
|
2174
|
+
if (missingPlugins.length > 0) {
|
|
2175
|
+
const lines = missingPlugins.map((m) => ` ${color.red(sym.fail)} ${m.id} ${dim(`\u2014 ${m.reason}`)}`);
|
|
2176
|
+
p3.log.warn(`Plugins declared in config but not loadable (${missingPlugins.length}):
|
|
2177
|
+
${lines.join("\n")}`);
|
|
2178
|
+
}
|
|
2179
|
+
p3.outro(missingPlugins.length > 0 ? color.red("Issues found") : color.green("OK"));
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
function collectMissingPlugins(config) {
|
|
2183
|
+
const missing = [];
|
|
2184
|
+
for (const [id, settings] of Object.entries(config.plugins ?? {})) {
|
|
2185
|
+
if (settings.enabled !== true) continue;
|
|
2186
|
+
try {
|
|
2187
|
+
loadPlugin(id);
|
|
2188
|
+
} catch (err) {
|
|
2189
|
+
if (err instanceof PluginNotFoundError) {
|
|
2190
|
+
missing.push({ id, reason: "unknown plugin id" });
|
|
2191
|
+
} else if (err instanceof PluginManifestError) {
|
|
2192
|
+
missing.push({ id, reason: err.message });
|
|
2193
|
+
} else {
|
|
2194
|
+
missing.push({ id, reason: err.message });
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
return missing;
|
|
2199
|
+
}
|
|
2200
|
+
function collectAssignments(config) {
|
|
2201
|
+
const overrides = config.agentAssignments ?? {};
|
|
2202
|
+
const out = [];
|
|
2203
|
+
for (const [pluginId, settings] of Object.entries(config.plugins ?? {})) {
|
|
2204
|
+
if (settings.enabled !== true) continue;
|
|
2205
|
+
let plugin;
|
|
2206
|
+
try {
|
|
2207
|
+
plugin = loadPlugin(pluginId);
|
|
2208
|
+
} catch {
|
|
2209
|
+
continue;
|
|
2210
|
+
}
|
|
2211
|
+
for (const entry of plugin.manifest.managed) {
|
|
2212
|
+
const overrideValue = overrides[entry.id];
|
|
2213
|
+
if (overrideValue) {
|
|
2214
|
+
out.push({ id: entry.id, agent: overrideValue, override: true });
|
|
2215
|
+
} else if (entry.recommendedAgent) {
|
|
2216
|
+
out.push({ id: entry.id, agent: entry.recommendedAgent, override: false });
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
return out;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// src/commands/sync.ts
|
|
2224
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
2225
|
+
import * as p4 from "@clack/prompts";
|
|
2226
|
+
import { existsSync as existsSync12, readFileSync as readFileSync9 } from "fs";
|
|
2227
|
+
import { resolve as resolve9 } from "path";
|
|
2228
|
+
|
|
2229
|
+
// src/lib/diff.ts
|
|
2230
|
+
function formatLineDiff(current, proposed, options = {}) {
|
|
2231
|
+
const a = (current ?? "").split("\n");
|
|
2232
|
+
const b = (proposed ?? "").split("\n");
|
|
2233
|
+
const max = Math.max(a.length, b.length);
|
|
2234
|
+
const lines = [];
|
|
2235
|
+
for (let i = 0; i < max; i++) {
|
|
2236
|
+
const ai = a[i] ?? "";
|
|
2237
|
+
const bi = b[i] ?? "";
|
|
2238
|
+
if (ai === bi) {
|
|
2239
|
+
lines.push(color.dim(` ${ai}`));
|
|
2240
|
+
} else {
|
|
2241
|
+
if (i < a.length) lines.push(color.red(`- ${ai}`));
|
|
2242
|
+
if (i < b.length) lines.push(color.green(`+ ${bi}`));
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
return lines.join("\n");
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// src/commands/sync.ts
|
|
2249
|
+
var syncCommand = defineCommand4({
|
|
2250
|
+
meta: {
|
|
2251
|
+
name: "sync",
|
|
2252
|
+
description: "Pull updates from managed Core into local files (with backups and conflict prompts)"
|
|
2253
|
+
},
|
|
2254
|
+
args: {
|
|
2255
|
+
cwd: { type: "string", description: "Directory to sync (default: cwd)" },
|
|
2256
|
+
"dry-run": { type: "boolean", description: "Show plan, do not write" },
|
|
2257
|
+
apply: { type: "boolean", description: "Apply changes (skip interactive prompt)" },
|
|
2258
|
+
yes: { type: "boolean", description: "Auto-confirm. Implies --apply. Fails with exit 1 if conflicts exist." },
|
|
2259
|
+
backup: {
|
|
2260
|
+
type: "boolean",
|
|
2261
|
+
default: true,
|
|
2262
|
+
description: "Backup CLAUDE.md before writing. Disable with --no-backup (not recommended)."
|
|
2263
|
+
}
|
|
2264
|
+
},
|
|
2265
|
+
async run({ args }) {
|
|
2266
|
+
const cwd = resolve9(args.cwd ?? process.cwd());
|
|
2267
|
+
const configPath = `${cwd}/navori.config.json`;
|
|
2268
|
+
const claudeMdPath = `${cwd}/CLAUDE.md`;
|
|
2269
|
+
p4.intro(brand("sync"));
|
|
2270
|
+
if (!existsSync12(cwd)) {
|
|
2271
|
+
p4.cancel(`Directory not found: ${cwd}`);
|
|
2272
|
+
process.exit(1);
|
|
2273
|
+
}
|
|
2274
|
+
if (!existsSync12(configPath)) {
|
|
2275
|
+
p4.cancel(`No navori.config.json at ${configPath}. Run 'navori init' first.`);
|
|
2276
|
+
process.exit(1);
|
|
2277
|
+
}
|
|
2278
|
+
const config = readConfig(configPath);
|
|
2279
|
+
const existing = existsSync12(claudeMdPath) ? readFileSync9(claudeMdPath, "utf-8") : "";
|
|
2280
|
+
const plan = computeRenderPlan(existing, config);
|
|
2281
|
+
reportPlan2(plan.entries);
|
|
2282
|
+
if (plan.updatesAvailable.length > 0) {
|
|
2283
|
+
const lines = plan.updatesAvailable.map(
|
|
2284
|
+
(u) => ` ${color.cyan(sym.update)} ${u.id} ${dim(`(${u.source} ${u.fromVersion} \u2192 ${u.toVersion})`)}`
|
|
2285
|
+
);
|
|
2286
|
+
p4.log.info(`Updates available (${plan.updatesAvailable.length}):
|
|
2287
|
+
${lines.join("\n")}`);
|
|
2288
|
+
}
|
|
2289
|
+
const conflicts = plan.entries.filter((e) => e.status === "user-modified-skipped");
|
|
2290
|
+
const hasOtherChanges = plan.changed;
|
|
2291
|
+
if (!hasOtherChanges && conflicts.length === 0) {
|
|
2292
|
+
p4.outro("Up to date \u2014 no changes");
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
if (args["dry-run"]) {
|
|
2296
|
+
if (conflicts.length > 0) renderConflictDiffs(existing, conflicts);
|
|
2297
|
+
const summary = [
|
|
2298
|
+
conflicts.length > 0 ? `${conflicts.length} conflict(s)` : null,
|
|
2299
|
+
hasOtherChanges ? "other changes pending" : null
|
|
2300
|
+
].filter(Boolean).join(", ");
|
|
2301
|
+
p4.outro(`Dry-run complete${summary ? ` \u2014 ${summary}` : ""}`);
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
const autoApply = Boolean(args.yes || args.apply);
|
|
2305
|
+
if (args.yes && conflicts.length > 0) {
|
|
2306
|
+
p4.cancel(`${conflicts.length} conflict(s) detected with --yes. Resolve interactively or use --apply alone.`);
|
|
2307
|
+
process.exit(1);
|
|
2308
|
+
}
|
|
2309
|
+
let applyConflicts = false;
|
|
2310
|
+
if (!autoApply) {
|
|
2311
|
+
if (conflicts.length > 0) {
|
|
2312
|
+
renderConflictDiffs(existing, conflicts);
|
|
2313
|
+
const choice = await p4.select({
|
|
2314
|
+
message: `Found ${conflicts.length} conflict(s). What do you want to do?`,
|
|
2315
|
+
options: [
|
|
2316
|
+
{ value: "skip-conflicts", label: "Apply non-conflict changes, keep my edits in conflicts" },
|
|
2317
|
+
{ value: "apply-all", label: "Apply ALL changes (overwrite my edits in conflicts)" },
|
|
2318
|
+
{ value: "abort", label: "Abort \u2014 write nothing" }
|
|
2319
|
+
]
|
|
2320
|
+
});
|
|
2321
|
+
if (p4.isCancel(choice) || choice === "abort") {
|
|
2322
|
+
p4.cancel("Aborted");
|
|
2323
|
+
process.exit(0);
|
|
2324
|
+
}
|
|
2325
|
+
applyConflicts = choice === "apply-all";
|
|
2326
|
+
} else {
|
|
2327
|
+
const ok = await p4.confirm({
|
|
2328
|
+
message: "Apply changes?",
|
|
2329
|
+
initialValue: true
|
|
2330
|
+
});
|
|
2331
|
+
if (p4.isCancel(ok) || !ok) {
|
|
2332
|
+
p4.cancel("Aborted");
|
|
2333
|
+
process.exit(0);
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
const skipIds = !applyConflicts ? new Set(conflicts.map((c) => c.asset.id)) : /* @__PURE__ */ new Set();
|
|
2338
|
+
const finalContent = applyPlanWithSkips(existing, config, skipIds);
|
|
2339
|
+
if (finalContent === existing) {
|
|
2340
|
+
p4.outro("Nothing to apply after conflict resolution");
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
let backupPath = null;
|
|
2344
|
+
const wantsBackup = args.backup !== false;
|
|
2345
|
+
if (wantsBackup) {
|
|
2346
|
+
const handle = createBackup(cwd, ["CLAUDE.md"]);
|
|
2347
|
+
backupPath = handle.path;
|
|
2348
|
+
const purged = purgeOldBackups();
|
|
2349
|
+
if (purged.length > 0) {
|
|
2350
|
+
p4.log.info(`Purged ${purged.length} backup(s) older than 30 days`);
|
|
2351
|
+
}
|
|
2352
|
+
} else {
|
|
2353
|
+
p4.log.warn("Backup omitted (--no-backup). No automatic undo for this sync.");
|
|
2354
|
+
}
|
|
2355
|
+
writeFileAtomic(claudeMdPath, finalContent);
|
|
2356
|
+
p4.log.success(`Wrote ${claudeMdPath}`);
|
|
2357
|
+
if (backupPath) p4.log.message(`${dim("Backup:")} ${backupPath}`);
|
|
2358
|
+
p4.outro(`${color.green("Done")} ${summarize2(plan.entries, conflicts.length, applyConflicts)}`);
|
|
2359
|
+
}
|
|
2360
|
+
});
|
|
2361
|
+
function summarize2(entries, conflictCount, appliedConflicts) {
|
|
2362
|
+
const counts = entries.reduce((acc, e) => {
|
|
2363
|
+
acc[e.status] = (acc[e.status] ?? 0) + 1;
|
|
2364
|
+
return acc;
|
|
2365
|
+
}, {});
|
|
2366
|
+
const parts = [];
|
|
2367
|
+
if (counts.created) parts.push(color.green(`${counts.created} created`));
|
|
2368
|
+
if (counts.updated) parts.push(color.yellow(`${counts.updated} updated`));
|
|
2369
|
+
if (conflictCount > 0) {
|
|
2370
|
+
parts.push(
|
|
2371
|
+
appliedConflicts ? color.red(`${conflictCount} conflict overwritten`) : color.red(`${conflictCount} conflict kept`)
|
|
2372
|
+
);
|
|
2373
|
+
}
|
|
2374
|
+
if (counts["removed-condition-false"]) parts.push(color.magenta(`${counts["removed-condition-false"]} removed`));
|
|
2375
|
+
if (counts.unchanged) parts.push(dim(`${counts.unchanged} unchanged`));
|
|
2376
|
+
return parts.length > 0 ? `${dim("\u2014")} ${parts.join(dim(", "))}` : "";
|
|
2377
|
+
}
|
|
2378
|
+
function reportPlan2(entries) {
|
|
2379
|
+
const lines = [];
|
|
2380
|
+
for (const e of entries) {
|
|
2381
|
+
const symStr = renderStatusSymbol(e.status);
|
|
2382
|
+
const label = renderStatusLabel(e.status);
|
|
2383
|
+
const cond = e.asset.condition ? dim(` [cond: ${e.asset.condition}]`) : "";
|
|
2384
|
+
lines.push(` ${symStr} ${e.asset.id} ${dim("(")}${label}${dim(")")}${cond}`);
|
|
2385
|
+
}
|
|
2386
|
+
p4.log.message(["Plan:", ...lines].join("\n"));
|
|
2387
|
+
}
|
|
2388
|
+
function renderConflictDiffs(existing, conflicts) {
|
|
2389
|
+
for (const c of conflicts) {
|
|
2390
|
+
const current = extractManagedContent(existing, c.asset.id);
|
|
2391
|
+
const diff = formatLineDiff(current, c.newContent);
|
|
2392
|
+
p4.log.warn(`Conflict in '${c.asset.id}':
|
|
2393
|
+
${diff}`);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// src/commands/add.ts
|
|
2398
|
+
import { defineCommand as defineCommand5 } from "citty";
|
|
2399
|
+
import * as p5 from "@clack/prompts";
|
|
2400
|
+
import { existsSync as existsSync14, readFileSync as readFileSync10 } from "fs";
|
|
2401
|
+
import { resolve as resolve10 } from "path";
|
|
2402
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2403
|
+
|
|
2404
|
+
// src/lib/which.ts
|
|
2405
|
+
import { existsSync as existsSync13, statSync as statSync6 } from "fs";
|
|
2406
|
+
import { join as join8 } from "path";
|
|
2407
|
+
function hasBinary(name) {
|
|
2408
|
+
const pathEnv = process.env.PATH ?? "";
|
|
2409
|
+
const sep2 = process.platform === "win32" ? ";" : ":";
|
|
2410
|
+
const dirs = pathEnv.split(sep2).filter(Boolean);
|
|
2411
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
|
|
2412
|
+
for (const dir of dirs) {
|
|
2413
|
+
for (const ext of exts) {
|
|
2414
|
+
const candidate = join8(dir, name + ext);
|
|
2415
|
+
if (existsSync13(candidate)) {
|
|
2416
|
+
try {
|
|
2417
|
+
if (statSync6(candidate).isFile()) return true;
|
|
2418
|
+
} catch {
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
return false;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// src/commands/add.ts
|
|
2427
|
+
function currentPlatform() {
|
|
2428
|
+
if (process.platform === "darwin") return "darwin";
|
|
2429
|
+
if (process.platform === "linux") return "linux";
|
|
2430
|
+
return "win32";
|
|
2431
|
+
}
|
|
2432
|
+
var INSTALL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
2433
|
+
function runShellCommand(cmd) {
|
|
2434
|
+
const result = spawnSync3(cmd, {
|
|
2435
|
+
shell: true,
|
|
2436
|
+
stdio: "inherit",
|
|
2437
|
+
timeout: INSTALL_TIMEOUT_MS
|
|
2438
|
+
});
|
|
2439
|
+
if (result.error && result.error.code === "ETIMEDOUT") {
|
|
2440
|
+
throw new Error(
|
|
2441
|
+
`Install command timed out after ${INSTALL_TIMEOUT_MS / 1e3}s. It may be waiting for interactive input (run from a TTY) or hung. Install the tool manually and re-run navori with --skip-install.`
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2444
|
+
if (result.signal) {
|
|
2445
|
+
throw new Error(`Command killed by signal ${result.signal}`);
|
|
2446
|
+
}
|
|
2447
|
+
if (result.status !== 0) {
|
|
2448
|
+
throw new Error(`Command exited with status ${result.status}`);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
var addCommand = defineCommand5({
|
|
2452
|
+
meta: {
|
|
2453
|
+
name: "add",
|
|
2454
|
+
description: "Register a plugin in navori.config.json and optionally install its external tool"
|
|
2455
|
+
},
|
|
2456
|
+
args: {
|
|
2457
|
+
plugin: {
|
|
2458
|
+
type: "positional",
|
|
2459
|
+
description: "Plugin id to add (e.g. engram)",
|
|
2460
|
+
required: true
|
|
2461
|
+
},
|
|
2462
|
+
cwd: {
|
|
2463
|
+
type: "string",
|
|
2464
|
+
description: "Directory containing navori.config.json (default: cwd)"
|
|
2465
|
+
},
|
|
2466
|
+
yes: {
|
|
2467
|
+
type: "boolean",
|
|
2468
|
+
description: "Skip prompts, install external tool if needed"
|
|
2469
|
+
},
|
|
2470
|
+
"skip-install": {
|
|
2471
|
+
type: "boolean",
|
|
2472
|
+
description: "Do not install external tool (register plugin only)"
|
|
2473
|
+
}
|
|
2474
|
+
},
|
|
2475
|
+
async run({ args }) {
|
|
2476
|
+
const cwd = resolve10(args.cwd ?? process.cwd());
|
|
2477
|
+
const configPath = `${cwd}/navori.config.json`;
|
|
2478
|
+
p5.intro(brand(`add ${accent(args.plugin)}`));
|
|
2479
|
+
if (!existsSync14(cwd)) {
|
|
2480
|
+
p5.cancel(`Directory not found: ${cwd}`);
|
|
2481
|
+
process.exit(1);
|
|
2482
|
+
}
|
|
2483
|
+
if (!existsSync14(configPath)) {
|
|
2484
|
+
p5.cancel(`No navori.config.json at ${configPath}. Run 'navori init' first.`);
|
|
2485
|
+
process.exit(1);
|
|
2486
|
+
}
|
|
2487
|
+
let plugin;
|
|
2488
|
+
try {
|
|
2489
|
+
plugin = loadPlugin(args.plugin);
|
|
2490
|
+
} catch (err) {
|
|
2491
|
+
if (err instanceof PluginNotFoundError) {
|
|
2492
|
+
p5.cancel(`Unknown plugin '${args.plugin}'. Known: ${listKnownPluginIds().join(", ") || "(none)"}`);
|
|
2493
|
+
process.exit(1);
|
|
2494
|
+
}
|
|
2495
|
+
if (err instanceof PluginManifestError) {
|
|
2496
|
+
p5.cancel(err.message);
|
|
2497
|
+
process.exit(1);
|
|
2498
|
+
}
|
|
2499
|
+
throw err;
|
|
2500
|
+
}
|
|
2501
|
+
p5.log.info(`${plugin.manifest.name} v${plugin.manifest.version}`);
|
|
2502
|
+
p5.log.message(plugin.manifest.description);
|
|
2503
|
+
const config = readConfig(configPath);
|
|
2504
|
+
const already = config.plugins?.[plugin.manifest.id]?.enabled === true;
|
|
2505
|
+
if (already) {
|
|
2506
|
+
p5.log.warn(`'${plugin.manifest.id}' is already enabled in this config`);
|
|
2507
|
+
} else {
|
|
2508
|
+
const updatedPlugins = {
|
|
2509
|
+
...config.plugins ?? {},
|
|
2510
|
+
[plugin.manifest.id]: { enabled: true }
|
|
2511
|
+
};
|
|
2512
|
+
const raw = JSON.parse(readFileSync10(configPath, "utf-8"));
|
|
2513
|
+
writeConfig(configPath, { ...raw, plugins: updatedPlugins });
|
|
2514
|
+
p5.log.success(`Added '${plugin.manifest.id}' to ${configPath}`);
|
|
2515
|
+
}
|
|
2516
|
+
const tool = plugin.manifest.externalTool;
|
|
2517
|
+
if (!tool) {
|
|
2518
|
+
p5.outro("Done \u2014 run 'navori render' to apply");
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
const installed = tool.checkBinary ? hasBinary(tool.checkBinary) : true;
|
|
2522
|
+
if (installed) {
|
|
2523
|
+
p5.log.success(`External tool '${tool.name}' is already installed`);
|
|
2524
|
+
p5.outro("Done \u2014 run 'navori render' to apply");
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
if (args["skip-install"]) {
|
|
2528
|
+
p5.log.warn(`External tool '${tool.name}' is not installed. Skip-install requested.`);
|
|
2529
|
+
p5.outro("Done \u2014 install manually later");
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
const platform = currentPlatform();
|
|
2533
|
+
const installCmd = tool.install?.[platform];
|
|
2534
|
+
if (!installCmd) {
|
|
2535
|
+
p5.log.warn(`No install command for platform '${platform}'. Install '${tool.name}' manually.`);
|
|
2536
|
+
p5.outro("Done");
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
const shouldInstall = args.yes ? true : await p5.confirm({
|
|
2540
|
+
message: `Install '${tool.name}'? Will run: ${installCmd}`,
|
|
2541
|
+
initialValue: false
|
|
2542
|
+
});
|
|
2543
|
+
if (p5.isCancel(shouldInstall) || !shouldInstall) {
|
|
2544
|
+
p5.log.warn(`External tool '${tool.name}' not installed. Hooks will skip silently.`);
|
|
2545
|
+
p5.outro("Done");
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
const spin = p5.spinner();
|
|
2549
|
+
try {
|
|
2550
|
+
spin.start(`Installing ${accent(tool.name)} \u2014 ${dim(installCmd)}`);
|
|
2551
|
+
runShellCommand(installCmd);
|
|
2552
|
+
if (tool.postInstall) {
|
|
2553
|
+
spin.message(`Post-install \u2014 ${dim(tool.postInstall)}`);
|
|
2554
|
+
runShellCommand(tool.postInstall);
|
|
2555
|
+
}
|
|
2556
|
+
spin.stop(`${color.green("\u2713")} Installed ${accent(tool.name)}`);
|
|
2557
|
+
} catch (err) {
|
|
2558
|
+
spin.stop(`${color.red("\u2717")} Install failed: ${err.message}`, 1);
|
|
2559
|
+
p5.outro(dim("Plugin registered but external tool install failed. Install manually."));
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
p5.outro(`${color.green("Done")} ${dim("\u2014 run 'navori render' to apply")}`);
|
|
2563
|
+
}
|
|
2564
|
+
});
|
|
2565
|
+
|
|
2566
|
+
// src/commands/workspace.ts
|
|
2567
|
+
import { defineCommand as defineCommand6 } from "citty";
|
|
2568
|
+
import * as p6 from "@clack/prompts";
|
|
2569
|
+
import { existsSync as existsSync15 } from "fs";
|
|
2570
|
+
var initSubCommand = defineCommand6({
|
|
2571
|
+
meta: {
|
|
2572
|
+
name: "init",
|
|
2573
|
+
description: "Create a new workspace at ~/.navori/workspaces/<name>.json"
|
|
2574
|
+
},
|
|
2575
|
+
args: {
|
|
2576
|
+
name: { type: "positional", description: "Workspace name (kebab-case)", required: true },
|
|
2577
|
+
description: { type: "string", description: "Workspace description" },
|
|
2578
|
+
yes: { type: "boolean", description: "Accept defaults without prompting" }
|
|
2579
|
+
},
|
|
2580
|
+
async run({ args }) {
|
|
2581
|
+
const name = args.name;
|
|
2582
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
2583
|
+
console.error(`Workspace name must be kebab-case: ${name}`);
|
|
2584
|
+
process.exit(1);
|
|
2585
|
+
}
|
|
2586
|
+
const path = workspacePath(name);
|
|
2587
|
+
if (existsSync15(path)) {
|
|
2588
|
+
console.error(`Workspace '${name}' already exists at ${path}`);
|
|
2589
|
+
process.exit(1);
|
|
2590
|
+
}
|
|
2591
|
+
p6.intro(brand(`workspace init ${accent(name)}`));
|
|
2592
|
+
let description = args.description ?? "";
|
|
2593
|
+
if (!args.yes && !description) {
|
|
2594
|
+
const value = await p6.text({
|
|
2595
|
+
message: "Workspace description (optional)",
|
|
2596
|
+
placeholder: "e.g. Bonum coaching platform \u2014 multi-repo"
|
|
2597
|
+
});
|
|
2598
|
+
if (p6.isCancel(value)) {
|
|
2599
|
+
p6.cancel("Cancelled");
|
|
2600
|
+
process.exit(0);
|
|
2601
|
+
}
|
|
2602
|
+
description = value.trim();
|
|
2603
|
+
}
|
|
2604
|
+
const workspace = {
|
|
2605
|
+
name,
|
|
2606
|
+
...description ? { description } : {},
|
|
2607
|
+
ticketsDir: "tickets",
|
|
2608
|
+
defaults: {},
|
|
2609
|
+
repos: []
|
|
2610
|
+
};
|
|
2611
|
+
const written = writeWorkspace(workspace);
|
|
2612
|
+
p6.log.success(`Wrote ${written}`);
|
|
2613
|
+
p6.log.message(`Tickets directory: ${workspaceDirectory(name)}/tickets/`);
|
|
2614
|
+
p6.outro(`Run 'navori workspace show ${name}' to inspect, or add it to a repo with 'navori init --workspace ${name}'.`);
|
|
2615
|
+
}
|
|
2616
|
+
});
|
|
2617
|
+
var lsSubCommand = defineCommand6({
|
|
2618
|
+
meta: {
|
|
2619
|
+
name: "ls",
|
|
2620
|
+
description: "List all known workspaces"
|
|
2621
|
+
},
|
|
2622
|
+
args: {
|
|
2623
|
+
json: { type: "boolean", description: "Output as JSON" }
|
|
2624
|
+
},
|
|
2625
|
+
run({ args }) {
|
|
2626
|
+
const names = listWorkspaces();
|
|
2627
|
+
if (args.json) {
|
|
2628
|
+
console.log(JSON.stringify(names, null, 2));
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
p6.intro(brand("workspace ls"));
|
|
2632
|
+
if (names.length === 0) {
|
|
2633
|
+
p6.log.info("No workspaces found. Create one with 'navori workspace init <name>'.");
|
|
2634
|
+
p6.outro(dim("Done"));
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
const lines = [];
|
|
2638
|
+
for (const name of names) {
|
|
2639
|
+
try {
|
|
2640
|
+
const ws = loadWorkspace(name);
|
|
2641
|
+
if (!ws) continue;
|
|
2642
|
+
const desc = ws.description ? dim(` \u2014 ${ws.description}`) : "";
|
|
2643
|
+
const count = ws.repos.length;
|
|
2644
|
+
const repoLabel = `${count} repo${count === 1 ? "" : "s"}`;
|
|
2645
|
+
lines.push(` ${color.cyan(sym.bullet)} ${accent(name)}${desc} ${dim(`(${repoLabel})`)}`);
|
|
2646
|
+
} catch {
|
|
2647
|
+
lines.push(` ${color.red(sym.fail)} ${name} ${dim("(invalid manifest)")}`);
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
p6.log.message(lines.join("\n"));
|
|
2651
|
+
p6.outro(dim(`${names.length} workspace${names.length === 1 ? "" : "s"}`));
|
|
2652
|
+
}
|
|
2653
|
+
});
|
|
2654
|
+
var showSubCommand = defineCommand6({
|
|
2655
|
+
meta: {
|
|
2656
|
+
name: "show",
|
|
2657
|
+
description: "Show details of a workspace"
|
|
2658
|
+
},
|
|
2659
|
+
args: {
|
|
2660
|
+
name: { type: "positional", description: "Workspace name", required: true },
|
|
2661
|
+
json: { type: "boolean", description: "Output as JSON" }
|
|
2662
|
+
},
|
|
2663
|
+
run({ args }) {
|
|
2664
|
+
const name = args.name;
|
|
2665
|
+
let workspace;
|
|
2666
|
+
try {
|
|
2667
|
+
workspace = loadWorkspace(name);
|
|
2668
|
+
} catch (err) {
|
|
2669
|
+
if (err instanceof WorkspaceError) {
|
|
2670
|
+
console.error(err.message);
|
|
2671
|
+
for (const issue of err.issues ?? []) {
|
|
2672
|
+
console.error(` - ${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
|
2673
|
+
}
|
|
2674
|
+
process.exit(1);
|
|
2675
|
+
}
|
|
2676
|
+
throw err;
|
|
2677
|
+
}
|
|
2678
|
+
if (!workspace) {
|
|
2679
|
+
process.stderr.write(
|
|
2680
|
+
`Workspace '${name}' not found at ${workspacePath(name)}.
|
|
2681
|
+
Create it with: navori workspace init ${name}
|
|
2682
|
+
Or list known workspaces: navori workspace ls
|
|
2683
|
+
`
|
|
2684
|
+
);
|
|
2685
|
+
process.exit(1);
|
|
2686
|
+
}
|
|
2687
|
+
if (args.json) {
|
|
2688
|
+
console.log(JSON.stringify(workspace, null, 2));
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
p6.intro(brand(`workspace show ${accent(workspace.name)}`));
|
|
2692
|
+
const rows = [];
|
|
2693
|
+
if (workspace.description) rows.push(["description", workspace.description]);
|
|
2694
|
+
rows.push(["path", workspacePath(name)]);
|
|
2695
|
+
rows.push(["directory", workspaceDirectory(name)]);
|
|
2696
|
+
rows.push(["ticketsDir", workspace.ticketsDir]);
|
|
2697
|
+
rows.push(["defaults", JSON.stringify(workspace.defaults)]);
|
|
2698
|
+
rows.push(["repos", String(workspace.repos.length)]);
|
|
2699
|
+
p6.log.message(kv(rows));
|
|
2700
|
+
if (workspace.repos.length > 0) {
|
|
2701
|
+
const repoLines = workspace.repos.map((repo) => {
|
|
2702
|
+
const stack = repo.stack ? dim(` [${repo.stack}]`) : "";
|
|
2703
|
+
const desc = repo.description ? dim(` \u2014 ${repo.description}`) : "";
|
|
2704
|
+
return ` ${color.cyan(sym.bullet)} ${accent(repo.name)}${stack} ${dim(repo.path)}${desc}`;
|
|
2705
|
+
});
|
|
2706
|
+
p6.log.message(`Repos:
|
|
2707
|
+
${repoLines.join("\n")}`);
|
|
2708
|
+
}
|
|
2709
|
+
p6.outro(dim("Done"));
|
|
2710
|
+
}
|
|
2711
|
+
});
|
|
2712
|
+
var renameSubCommand = defineCommand6({
|
|
2713
|
+
meta: {
|
|
2714
|
+
name: "rename",
|
|
2715
|
+
description: "Rename a workspace (preserves tickets, repos, defaults)"
|
|
2716
|
+
},
|
|
2717
|
+
args: {
|
|
2718
|
+
from: { type: "positional", description: "Current workspace name", required: true },
|
|
2719
|
+
to: { type: "positional", description: "New workspace name (kebab-case)", required: true },
|
|
2720
|
+
yes: { type: "boolean", description: "Skip confirmation" }
|
|
2721
|
+
},
|
|
2722
|
+
async run({ args }) {
|
|
2723
|
+
const from = args.from;
|
|
2724
|
+
const to = args.to;
|
|
2725
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(to)) {
|
|
2726
|
+
console.error(`Workspace name must be kebab-case: ${to}`);
|
|
2727
|
+
process.exit(1);
|
|
2728
|
+
}
|
|
2729
|
+
if (from === to) {
|
|
2730
|
+
console.error("Source and destination names are the same");
|
|
2731
|
+
process.exit(1);
|
|
2732
|
+
}
|
|
2733
|
+
const ws = loadWorkspace(from);
|
|
2734
|
+
if (!ws) {
|
|
2735
|
+
console.error(`Workspace '${from}' not found`);
|
|
2736
|
+
process.exit(1);
|
|
2737
|
+
}
|
|
2738
|
+
if (loadWorkspace(to)) {
|
|
2739
|
+
console.error(`Workspace '${to}' already exists. Choose a different name or delete it first.`);
|
|
2740
|
+
process.exit(1);
|
|
2741
|
+
}
|
|
2742
|
+
p6.intro(brand(`workspace rename ${accent(from)} ${dim("\u2192")} ${accent(to)}`));
|
|
2743
|
+
p6.log.message(
|
|
2744
|
+
`Will rename the workspace directory and update the manifest's 'name' field. ${ws.repos.length} repo registration(s) and any tickets will be preserved.`
|
|
2745
|
+
);
|
|
2746
|
+
p6.log.warn(
|
|
2747
|
+
`Repos that have 'workspace: ${from}' in their navori.config.json must be updated manually: cd to each repo and run 'navori configure workspace ${to}'.`
|
|
2748
|
+
);
|
|
2749
|
+
if (!args.yes) {
|
|
2750
|
+
const ok = await p6.confirm({
|
|
2751
|
+
message: `Rename workspace '${from}' to '${to}'?`,
|
|
2752
|
+
initialValue: false
|
|
2753
|
+
});
|
|
2754
|
+
if (p6.isCancel(ok) || !ok) {
|
|
2755
|
+
p6.cancel("Aborted");
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
const { renameSync: renameSync3 } = await import("fs");
|
|
2760
|
+
const oldDir = workspaceDirectory(from);
|
|
2761
|
+
const newDir = workspaceDirectory(to);
|
|
2762
|
+
renameSync3(oldDir, newDir);
|
|
2763
|
+
const renamed = { ...ws, name: to };
|
|
2764
|
+
writeWorkspace(renamed);
|
|
2765
|
+
p6.outro(`Renamed. New path: ${newDir}`);
|
|
2766
|
+
}
|
|
2767
|
+
});
|
|
2768
|
+
var deleteSubCommand = defineCommand6({
|
|
2769
|
+
meta: {
|
|
2770
|
+
name: "delete",
|
|
2771
|
+
description: "Delete a workspace (move to ~/.navori/.trash for safety)"
|
|
2772
|
+
},
|
|
2773
|
+
args: {
|
|
2774
|
+
name: { type: "positional", description: "Workspace name", required: true },
|
|
2775
|
+
yes: { type: "boolean", description: "Skip confirmation" }
|
|
2776
|
+
},
|
|
2777
|
+
async run({ args }) {
|
|
2778
|
+
const name = args.name;
|
|
2779
|
+
const ws = loadWorkspace(name);
|
|
2780
|
+
if (!ws) {
|
|
2781
|
+
console.error(`Workspace '${name}' not found`);
|
|
2782
|
+
process.exit(1);
|
|
2783
|
+
}
|
|
2784
|
+
const dir = workspaceDirectory(name);
|
|
2785
|
+
p6.intro(brand(`workspace delete ${accent(name)}`));
|
|
2786
|
+
p6.log.warn(
|
|
2787
|
+
`Will move ${dir} to ~/.navori/.trash/. Includes ${ws.repos.length} repo registration(s) and any tickets in that workspace.`
|
|
2788
|
+
);
|
|
2789
|
+
if (!args.yes) {
|
|
2790
|
+
const ok = await p6.confirm({
|
|
2791
|
+
message: `Delete workspace '${name}'?`,
|
|
2792
|
+
initialValue: false
|
|
2793
|
+
});
|
|
2794
|
+
if (p6.isCancel(ok) || !ok) {
|
|
2795
|
+
p6.cancel("Aborted");
|
|
2796
|
+
return;
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
const { renameSync: renameSync3, existsSync: existsSync21, mkdirSync: mkdirSync7 } = await import("fs");
|
|
2800
|
+
const { join: joinPath } = await import("path");
|
|
2801
|
+
const { homedir: homedir2 } = await import("os");
|
|
2802
|
+
const trashRoot = joinPath(homedir2(), ".navori", ".trash");
|
|
2803
|
+
mkdirSync7(trashRoot, { recursive: true });
|
|
2804
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2805
|
+
const dest = joinPath(trashRoot, `${name}-${ts}`);
|
|
2806
|
+
if (existsSync21(dir)) renameSync3(dir, dest);
|
|
2807
|
+
p6.outro(`Moved to ${dest}. Restore manually if needed.`);
|
|
2808
|
+
}
|
|
2809
|
+
});
|
|
2810
|
+
var addRepoSubCommand = defineCommand6({
|
|
2811
|
+
meta: {
|
|
2812
|
+
name: "add-repo",
|
|
2813
|
+
description: "Register a repo inside a workspace"
|
|
2814
|
+
},
|
|
2815
|
+
args: {
|
|
2816
|
+
workspace: { type: "positional", description: "Workspace name", required: true },
|
|
2817
|
+
name: { type: "string", description: "Repo name (kebab-case)", required: true },
|
|
2818
|
+
path: { type: "string", description: "Absolute path to the repo", required: true },
|
|
2819
|
+
stack: { type: "string", description: "Stack tag (optional)" },
|
|
2820
|
+
description: { type: "string", description: "Repo description (optional)" }
|
|
2821
|
+
},
|
|
2822
|
+
run({ args }) {
|
|
2823
|
+
const ws = loadWorkspace(args.workspace);
|
|
2824
|
+
if (!ws) {
|
|
2825
|
+
console.error(`Workspace '${args.workspace}' not found`);
|
|
2826
|
+
process.exit(1);
|
|
2827
|
+
}
|
|
2828
|
+
if (ws.repos.some((r) => r.name === args.name)) {
|
|
2829
|
+
console.error(`Repo '${args.name}' already registered in workspace '${ws.name}'`);
|
|
2830
|
+
process.exit(1);
|
|
2831
|
+
}
|
|
2832
|
+
ws.repos.push({
|
|
2833
|
+
name: args.name,
|
|
2834
|
+
path: args.path,
|
|
2835
|
+
...args.stack ? { stack: args.stack } : {},
|
|
2836
|
+
...args.description ? { description: args.description } : {}
|
|
2837
|
+
});
|
|
2838
|
+
const written = writeWorkspace(ws);
|
|
2839
|
+
p6.intro(brand(`workspace add-repo ${accent(ws.name)}`));
|
|
2840
|
+
p6.log.success(`Registered '${accent(args.name)}' (${dim(written)})`);
|
|
2841
|
+
p6.outro(dim("Done"));
|
|
2842
|
+
}
|
|
2843
|
+
});
|
|
2844
|
+
var workspaceCommand = defineCommand6({
|
|
2845
|
+
meta: {
|
|
2846
|
+
name: "workspace",
|
|
2847
|
+
description: "Manage navori workspaces (cross-repo config + tickets)"
|
|
2848
|
+
},
|
|
2849
|
+
subCommands: {
|
|
2850
|
+
init: initSubCommand,
|
|
2851
|
+
ls: lsSubCommand,
|
|
2852
|
+
show: showSubCommand,
|
|
2853
|
+
"add-repo": addRepoSubCommand,
|
|
2854
|
+
rename: renameSubCommand,
|
|
2855
|
+
delete: deleteSubCommand
|
|
2856
|
+
}
|
|
2857
|
+
});
|
|
2858
|
+
|
|
2859
|
+
// src/commands/ticket.ts
|
|
2860
|
+
import { defineCommand as defineCommand7 } from "citty";
|
|
2861
|
+
import * as p7 from "@clack/prompts";
|
|
2862
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
2863
|
+
|
|
2864
|
+
// src/lib/tickets.ts
|
|
2865
|
+
import { existsSync as existsSync16, readFileSync as readFileSync11, readdirSync as readdirSync6, statSync as statSync7, mkdirSync as mkdirSync4, renameSync as renameSync2, rmSync as rmSync5 } from "fs";
|
|
2866
|
+
import { join as join9, resolve as resolve11 } from "path";
|
|
2867
|
+
var TicketError = class extends Error {
|
|
2868
|
+
};
|
|
2869
|
+
function ticketsDir(workspaceName) {
|
|
2870
|
+
const ws = loadWorkspace(workspaceName);
|
|
2871
|
+
if (!ws) throw new TicketError(`Workspace '${workspaceName}' not found`);
|
|
2872
|
+
return join9(workspaceDirectory(workspaceName), ws.ticketsDir);
|
|
2873
|
+
}
|
|
2874
|
+
function readTitle(path) {
|
|
2875
|
+
try {
|
|
2876
|
+
const content = readFileSync11(path, "utf-8").split("\n");
|
|
2877
|
+
for (const line of content) {
|
|
2878
|
+
const trimmed = line.trim();
|
|
2879
|
+
if (!trimmed) continue;
|
|
2880
|
+
return trimmed.replace(/^#+\s+/, "");
|
|
2881
|
+
}
|
|
2882
|
+
return "(empty)";
|
|
2883
|
+
} catch {
|
|
2884
|
+
return "(unreadable)";
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
function listTickets(workspaceName) {
|
|
2888
|
+
const dir = ticketsDir(workspaceName);
|
|
2889
|
+
if (!existsSync16(dir)) return [];
|
|
2890
|
+
const out = [];
|
|
2891
|
+
const collect = (folder, state) => {
|
|
2892
|
+
if (!existsSync16(folder)) return;
|
|
2893
|
+
for (const entry of readdirSync6(folder)) {
|
|
2894
|
+
if (!entry.endsWith(".md")) continue;
|
|
2895
|
+
const full = join9(folder, entry);
|
|
2896
|
+
try {
|
|
2897
|
+
if (!statSync7(full).isFile()) continue;
|
|
2898
|
+
} catch {
|
|
2899
|
+
continue;
|
|
2900
|
+
}
|
|
2901
|
+
out.push({
|
|
2902
|
+
id: entry.replace(/\.md$/, ""),
|
|
2903
|
+
path: full,
|
|
2904
|
+
title: readTitle(full),
|
|
2905
|
+
state
|
|
2906
|
+
});
|
|
2907
|
+
}
|
|
2908
|
+
};
|
|
2909
|
+
collect(dir, "active");
|
|
2910
|
+
collect(join9(dir, "_archive"), "archive");
|
|
2911
|
+
return out;
|
|
2912
|
+
}
|
|
2913
|
+
function findTicket(workspaceName, id) {
|
|
2914
|
+
const all = listTickets(workspaceName);
|
|
2915
|
+
return all.find((t) => t.id === id) ?? null;
|
|
2916
|
+
}
|
|
2917
|
+
function defaultTemplate(id, title) {
|
|
2918
|
+
return [
|
|
2919
|
+
`# ${title}`,
|
|
2920
|
+
"",
|
|
2921
|
+
`**ID**: ${id}`,
|
|
2922
|
+
"",
|
|
2923
|
+
"## Goal",
|
|
2924
|
+
"<one-liner of what this ticket achieves>",
|
|
2925
|
+
"",
|
|
2926
|
+
"## Repos affected",
|
|
2927
|
+
"- ",
|
|
2928
|
+
"",
|
|
2929
|
+
"## Scope",
|
|
2930
|
+
"- ",
|
|
2931
|
+
"",
|
|
2932
|
+
"## Notes",
|
|
2933
|
+
"- ",
|
|
2934
|
+
"",
|
|
2935
|
+
"## Links",
|
|
2936
|
+
"- ",
|
|
2937
|
+
""
|
|
2938
|
+
].join("\n");
|
|
2939
|
+
}
|
|
2940
|
+
function archiveTicket(workspaceName, id) {
|
|
2941
|
+
const summary = findTicket(workspaceName, id);
|
|
2942
|
+
if (!summary) throw new TicketError(`Ticket '${id}' not found in workspace '${workspaceName}'`);
|
|
2943
|
+
if (summary.state === "archive") return summary;
|
|
2944
|
+
const dir = ticketsDir(workspaceName);
|
|
2945
|
+
const archiveDir = join9(dir, "_archive");
|
|
2946
|
+
mkdirSync4(archiveDir, { recursive: true });
|
|
2947
|
+
const dest = join9(archiveDir, `${id}.md`);
|
|
2948
|
+
renameSync2(summary.path, dest);
|
|
2949
|
+
return { id, path: dest, title: summary.title, state: "archive" };
|
|
2950
|
+
}
|
|
2951
|
+
function deleteTicket(workspaceName, id) {
|
|
2952
|
+
const summary = findTicket(workspaceName, id);
|
|
2953
|
+
if (!summary) throw new TicketError(`Ticket '${id}' not found in workspace '${workspaceName}'`);
|
|
2954
|
+
rmSync5(summary.path, { force: true });
|
|
2955
|
+
}
|
|
2956
|
+
function createTicket(workspaceName, id, title) {
|
|
2957
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-_]*$/.test(id)) {
|
|
2958
|
+
throw new TicketError(`Invalid ticket id '${id}'. Use letters, digits, hyphens, underscores.`);
|
|
2959
|
+
}
|
|
2960
|
+
const dir = ticketsDir(workspaceName);
|
|
2961
|
+
if (!existsSync16(dir)) throw new TicketError(`Tickets directory does not exist: ${dir}`);
|
|
2962
|
+
const path = join9(dir, `${id}.md`);
|
|
2963
|
+
if (existsSync16(path)) throw new TicketError(`Ticket '${id}' already exists at ${path}`);
|
|
2964
|
+
const finalTitle = title?.trim() || id;
|
|
2965
|
+
writeFileAtomic(path, defaultTemplate(id, finalTitle));
|
|
2966
|
+
return {
|
|
2967
|
+
id,
|
|
2968
|
+
path,
|
|
2969
|
+
title: finalTitle,
|
|
2970
|
+
state: "active"
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
function findReferencingRepos(repoPaths, ticketId) {
|
|
2974
|
+
const result = [];
|
|
2975
|
+
const idPattern = new RegExp(`\\b${ticketId}\\b`);
|
|
2976
|
+
for (const repoPath of repoPaths) {
|
|
2977
|
+
const abs = resolve11(repoPath);
|
|
2978
|
+
const current = join9(abs, "progress", "current.md");
|
|
2979
|
+
if (!existsSync16(current)) continue;
|
|
2980
|
+
try {
|
|
2981
|
+
const content = readFileSync11(current, "utf-8");
|
|
2982
|
+
const matches = [];
|
|
2983
|
+
for (const line of content.split("\n")) {
|
|
2984
|
+
if (idPattern.test(line)) matches.push(line.trim());
|
|
2985
|
+
}
|
|
2986
|
+
if (matches.length > 0) {
|
|
2987
|
+
result.push({ path: abs, matches });
|
|
2988
|
+
}
|
|
2989
|
+
} catch {
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
return result;
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
// src/commands/ticket.ts
|
|
2996
|
+
function handleTicketError(err) {
|
|
2997
|
+
if (err instanceof TicketError) {
|
|
2998
|
+
console.error(err.message);
|
|
2999
|
+
process.exit(1);
|
|
3000
|
+
}
|
|
3001
|
+
throw err;
|
|
3002
|
+
}
|
|
3003
|
+
var listSubCommand = defineCommand7({
|
|
3004
|
+
meta: {
|
|
3005
|
+
name: "list",
|
|
3006
|
+
description: "List tickets in a workspace"
|
|
3007
|
+
},
|
|
3008
|
+
args: {
|
|
3009
|
+
workspace: { type: "positional", description: "Workspace name", required: true },
|
|
3010
|
+
archive: { type: "boolean", description: "Include archived tickets" },
|
|
3011
|
+
json: { type: "boolean", description: "Output as JSON" }
|
|
3012
|
+
},
|
|
3013
|
+
run({ args }) {
|
|
3014
|
+
let tickets;
|
|
3015
|
+
try {
|
|
3016
|
+
tickets = listTickets(args.workspace);
|
|
3017
|
+
} catch (err) {
|
|
3018
|
+
handleTicketError(err);
|
|
3019
|
+
}
|
|
3020
|
+
const filtered = args.archive ? tickets : tickets.filter((t) => t.state === "active");
|
|
3021
|
+
if (args.json) {
|
|
3022
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
3023
|
+
return;
|
|
3024
|
+
}
|
|
3025
|
+
p7.intro(brand(`ticket list ${accent(args.workspace)}`));
|
|
3026
|
+
if (filtered.length === 0) {
|
|
3027
|
+
p7.log.info(`No tickets. Create one with 'navori ticket new ${args.workspace} <id>'.`);
|
|
3028
|
+
p7.outro(dim("Done"));
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
const lines = filtered.map((t) => {
|
|
3032
|
+
const badge = t.state === "archive" ? color.magenta(" [archive]") : "";
|
|
3033
|
+
return ` ${color.cyan(sym.bullet)} ${accent(t.id)}${badge} ${t.title}`;
|
|
3034
|
+
});
|
|
3035
|
+
p7.log.message(lines.join("\n"));
|
|
3036
|
+
p7.outro(dim(`${filtered.length} ticket${filtered.length === 1 ? "" : "s"}`));
|
|
3037
|
+
}
|
|
3038
|
+
});
|
|
3039
|
+
var showSubCommand2 = defineCommand7({
|
|
3040
|
+
meta: {
|
|
3041
|
+
name: "show",
|
|
3042
|
+
description: "Show a ticket and which repos reference it"
|
|
3043
|
+
},
|
|
3044
|
+
args: {
|
|
3045
|
+
workspace: { type: "positional", description: "Workspace name", required: true },
|
|
3046
|
+
id: { type: "positional", description: "Ticket id", required: true },
|
|
3047
|
+
json: { type: "boolean", description: "Output as JSON" }
|
|
3048
|
+
},
|
|
3049
|
+
run({ args }) {
|
|
3050
|
+
let ticket;
|
|
3051
|
+
try {
|
|
3052
|
+
ticket = findTicket(args.workspace, args.id);
|
|
3053
|
+
} catch (err) {
|
|
3054
|
+
handleTicketError(err);
|
|
3055
|
+
}
|
|
3056
|
+
if (!ticket) {
|
|
3057
|
+
process.stderr.write(
|
|
3058
|
+
`Ticket '${args.id}' not found in workspace '${args.workspace}'.
|
|
3059
|
+
Create it with: navori ticket new ${args.workspace} ${args.id}
|
|
3060
|
+
`
|
|
3061
|
+
);
|
|
3062
|
+
process.exit(1);
|
|
3063
|
+
}
|
|
3064
|
+
const ws = loadWorkspace(args.workspace);
|
|
3065
|
+
const repoPaths = (ws?.repos ?? []).map((r) => r.path);
|
|
3066
|
+
const referencing = findReferencingRepos(repoPaths, args.id);
|
|
3067
|
+
if (args.json) {
|
|
3068
|
+
const content = readFileSync12(ticket.path, "utf-8");
|
|
3069
|
+
console.log(JSON.stringify({ ticket, referencing, content }, null, 2));
|
|
3070
|
+
return;
|
|
3071
|
+
}
|
|
3072
|
+
p7.intro(brand(`ticket show ${accent(ticket.id)}`));
|
|
3073
|
+
p7.log.message(
|
|
3074
|
+
kv([
|
|
3075
|
+
["title", ticket.title],
|
|
3076
|
+
["state", ticket.state],
|
|
3077
|
+
["path", ticket.path]
|
|
3078
|
+
])
|
|
3079
|
+
);
|
|
3080
|
+
p7.note(readFileSync12(ticket.path, "utf-8"), "Content");
|
|
3081
|
+
if (referencing.length === 0) {
|
|
3082
|
+
p7.log.message(dim("Referenced in: (no repo's progress/current.md mentions this ticket)"));
|
|
3083
|
+
} else {
|
|
3084
|
+
const refLines = referencing.flatMap((ref) => [
|
|
3085
|
+
` ${color.cyan(sym.bullet)} ${ref.path}`,
|
|
3086
|
+
...ref.matches.map((match) => ` ${dim(">")} ${dim(match)}`)
|
|
3087
|
+
]);
|
|
3088
|
+
p7.log.message(`Referenced in:
|
|
3089
|
+
${refLines.join("\n")}`);
|
|
3090
|
+
}
|
|
3091
|
+
p7.outro(dim("Done"));
|
|
3092
|
+
}
|
|
3093
|
+
});
|
|
3094
|
+
var newSubCommand = defineCommand7({
|
|
3095
|
+
meta: {
|
|
3096
|
+
name: "new",
|
|
3097
|
+
description: "Create a new ticket in a workspace"
|
|
3098
|
+
},
|
|
3099
|
+
args: {
|
|
3100
|
+
workspace: { type: "positional", description: "Workspace name", required: true },
|
|
3101
|
+
id: { type: "positional", description: "Ticket id (e.g. BNM-123)", required: true },
|
|
3102
|
+
title: { type: "string", description: "Ticket title (default: id)" }
|
|
3103
|
+
},
|
|
3104
|
+
async run({ args }) {
|
|
3105
|
+
p7.intro(brand(`ticket new ${accent(args.id)}`));
|
|
3106
|
+
const id = args.id;
|
|
3107
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-_]*$/.test(id)) {
|
|
3108
|
+
p7.cancel(`Invalid ticket id '${id}'. Use letters, digits, hyphens, underscores (must start alphanumeric).`);
|
|
3109
|
+
process.exit(1);
|
|
3110
|
+
}
|
|
3111
|
+
let title = args.title;
|
|
3112
|
+
if (!title) {
|
|
3113
|
+
const value = await p7.text({
|
|
3114
|
+
message: "Ticket title",
|
|
3115
|
+
placeholder: id,
|
|
3116
|
+
defaultValue: id
|
|
3117
|
+
});
|
|
3118
|
+
if (p7.isCancel(value)) {
|
|
3119
|
+
p7.cancel("Cancelled");
|
|
3120
|
+
process.exit(0);
|
|
3121
|
+
}
|
|
3122
|
+
title = value;
|
|
3123
|
+
}
|
|
3124
|
+
let ticket;
|
|
3125
|
+
try {
|
|
3126
|
+
ticket = createTicket(args.workspace, args.id, title);
|
|
3127
|
+
} catch (err) {
|
|
3128
|
+
if (err instanceof TicketError) {
|
|
3129
|
+
p7.cancel(err.message);
|
|
3130
|
+
process.exit(1);
|
|
3131
|
+
}
|
|
3132
|
+
throw err;
|
|
3133
|
+
}
|
|
3134
|
+
p7.log.success(`Wrote ${ticket.path}`);
|
|
3135
|
+
p7.outro(`Reference it from a repo's progress/current.md with:
|
|
3136
|
+
ticket: ${args.id}`);
|
|
3137
|
+
}
|
|
3138
|
+
});
|
|
3139
|
+
var archiveSubCommand = defineCommand7({
|
|
3140
|
+
meta: {
|
|
3141
|
+
name: "archive",
|
|
3142
|
+
description: "Move a ticket to the _archive folder (reversible)"
|
|
3143
|
+
},
|
|
3144
|
+
args: {
|
|
3145
|
+
workspace: { type: "positional", description: "Workspace name", required: true },
|
|
3146
|
+
id: { type: "positional", description: "Ticket id", required: true }
|
|
3147
|
+
},
|
|
3148
|
+
run({ args }) {
|
|
3149
|
+
try {
|
|
3150
|
+
const result = archiveTicket(args.workspace, args.id);
|
|
3151
|
+
p7.intro(brand(`ticket archive ${accent(args.id)}`));
|
|
3152
|
+
p7.log.success(`Archived \u2192 ${dim(result.path)}`);
|
|
3153
|
+
p7.outro(dim("Done"));
|
|
3154
|
+
} catch (err) {
|
|
3155
|
+
if (err instanceof TicketError) {
|
|
3156
|
+
console.error(err.message);
|
|
3157
|
+
process.exit(1);
|
|
3158
|
+
}
|
|
3159
|
+
throw err;
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
});
|
|
3163
|
+
var deleteSubCommand2 = defineCommand7({
|
|
3164
|
+
meta: {
|
|
3165
|
+
name: "delete",
|
|
3166
|
+
description: "Delete a ticket permanently"
|
|
3167
|
+
},
|
|
3168
|
+
args: {
|
|
3169
|
+
workspace: { type: "positional", description: "Workspace name", required: true },
|
|
3170
|
+
id: { type: "positional", description: "Ticket id", required: true },
|
|
3171
|
+
yes: { type: "boolean", description: "Skip confirmation" }
|
|
3172
|
+
},
|
|
3173
|
+
async run({ args }) {
|
|
3174
|
+
p7.intro(brand(`ticket delete ${accent(args.id)}`));
|
|
3175
|
+
if (!args.yes) {
|
|
3176
|
+
const ok = await p7.confirm({
|
|
3177
|
+
message: `Permanently delete ticket '${args.id}' from workspace '${args.workspace}'?`,
|
|
3178
|
+
initialValue: false
|
|
3179
|
+
});
|
|
3180
|
+
if (p7.isCancel(ok) || !ok) {
|
|
3181
|
+
p7.cancel("Aborted");
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
try {
|
|
3186
|
+
deleteTicket(args.workspace, args.id);
|
|
3187
|
+
p7.outro("Deleted");
|
|
3188
|
+
} catch (err) {
|
|
3189
|
+
if (err instanceof TicketError) {
|
|
3190
|
+
p7.cancel(err.message);
|
|
3191
|
+
process.exit(1);
|
|
3192
|
+
}
|
|
3193
|
+
throw err;
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
});
|
|
3197
|
+
var ticketCommand = defineCommand7({
|
|
3198
|
+
meta: {
|
|
3199
|
+
name: "ticket",
|
|
3200
|
+
description: "Manage tickets-as-files inside a workspace"
|
|
3201
|
+
},
|
|
3202
|
+
subCommands: {
|
|
3203
|
+
list: listSubCommand,
|
|
3204
|
+
show: showSubCommand2,
|
|
3205
|
+
new: newSubCommand,
|
|
3206
|
+
archive: archiveSubCommand,
|
|
3207
|
+
delete: deleteSubCommand2
|
|
3208
|
+
}
|
|
3209
|
+
});
|
|
3210
|
+
|
|
3211
|
+
// src/commands/configure.ts
|
|
3212
|
+
import { defineCommand as defineCommand8 } from "citty";
|
|
3213
|
+
import * as p8 from "@clack/prompts";
|
|
3214
|
+
import { existsSync as existsSync17, readFileSync as readFileSync13 } from "fs";
|
|
3215
|
+
import { resolve as resolve12 } from "path";
|
|
3216
|
+
var ENGINE_OPTIONS2 = [
|
|
3217
|
+
{ value: "claude", label: "Claude Code (.claude/)" },
|
|
3218
|
+
{ value: "agents-md", label: "AGENTS.md (universal \u2014 Cursor / Codex / Gemini read it)" },
|
|
3219
|
+
{ value: "cursor", label: "Cursor (.cursor/rules/)" },
|
|
3220
|
+
{ value: "copilot", label: "Copilot (.github/copilot-instructions.md)" }
|
|
3221
|
+
];
|
|
3222
|
+
function fail(msg) {
|
|
3223
|
+
process.stderr.write(`navori: ${msg}
|
|
3224
|
+
`);
|
|
3225
|
+
process.exit(1);
|
|
3226
|
+
}
|
|
3227
|
+
function loadOrExit(cwd) {
|
|
3228
|
+
if (!existsSync17(cwd)) fail(`Directory not found: ${cwd}`);
|
|
3229
|
+
const configPath = resolve12(cwd, "navori.config.json");
|
|
3230
|
+
if (!existsSync17(configPath)) fail(`No navori.config.json at ${configPath}. Run 'navori init' first.`);
|
|
3231
|
+
const config = readConfig(configPath);
|
|
3232
|
+
const raw = JSON.parse(readFileSync13(configPath, "utf-8"));
|
|
3233
|
+
return { config, path: configPath, raw };
|
|
3234
|
+
}
|
|
3235
|
+
function persist(path, raw) {
|
|
3236
|
+
const next = { ...raw };
|
|
3237
|
+
delete next.$schema;
|
|
3238
|
+
writeConfig(path, next);
|
|
3239
|
+
}
|
|
3240
|
+
var pluginsSubCommand = defineCommand8({
|
|
3241
|
+
meta: {
|
|
3242
|
+
name: "plugins",
|
|
3243
|
+
description: "Enable or disable plugins for this repo"
|
|
3244
|
+
},
|
|
3245
|
+
args: {
|
|
3246
|
+
cwd: { type: "string", description: "Directory (default: cwd)" }
|
|
3247
|
+
},
|
|
3248
|
+
async run({ args }) {
|
|
3249
|
+
const cwd = resolve12(args.cwd ?? process.cwd());
|
|
3250
|
+
const { config, path, raw } = loadOrExit(cwd);
|
|
3251
|
+
p8.intro(brand("configure plugins"));
|
|
3252
|
+
const allIds = listKnownPluginIds();
|
|
3253
|
+
const current = config.plugins ?? {};
|
|
3254
|
+
const enabledNow = new Set(
|
|
3255
|
+
Object.entries(current).filter(([, v]) => v.enabled).map(([k]) => k)
|
|
3256
|
+
);
|
|
3257
|
+
const options = allIds.map((id) => {
|
|
3258
|
+
let plugin;
|
|
3259
|
+
try {
|
|
3260
|
+
plugin = loadPlugin(id);
|
|
3261
|
+
} catch {
|
|
3262
|
+
return null;
|
|
3263
|
+
}
|
|
3264
|
+
return {
|
|
3265
|
+
value: id,
|
|
3266
|
+
label: `${plugin.manifest.name} (${id})`,
|
|
3267
|
+
hint: plugin.manifest.description
|
|
3268
|
+
};
|
|
3269
|
+
}).filter((o) => o !== null);
|
|
3270
|
+
const selected = await p8.multiselect({
|
|
3271
|
+
message: "Plugins enabled in this repo",
|
|
3272
|
+
options,
|
|
3273
|
+
required: false,
|
|
3274
|
+
initialValues: [...enabledNow]
|
|
3275
|
+
});
|
|
3276
|
+
if (p8.isCancel(selected)) {
|
|
3277
|
+
p8.cancel("Cancelled");
|
|
3278
|
+
return;
|
|
3279
|
+
}
|
|
3280
|
+
const selectedSet = new Set(selected);
|
|
3281
|
+
const newPlugins = {};
|
|
3282
|
+
for (const id of selectedSet) newPlugins[id] = { enabled: true };
|
|
3283
|
+
raw.plugins = newPlugins;
|
|
3284
|
+
persist(path, raw);
|
|
3285
|
+
const added = [...selectedSet].filter((id) => !enabledNow.has(id));
|
|
3286
|
+
const removed = [...enabledNow].filter((id) => !selectedSet.has(id));
|
|
3287
|
+
if (added.length > 0) p8.log.success(`Enabled: ${added.join(", ")}`);
|
|
3288
|
+
if (removed.length > 0) p8.log.warn(`Disabled: ${removed.join(", ")}`);
|
|
3289
|
+
if (added.length === 0 && removed.length === 0) p8.log.info("No changes");
|
|
3290
|
+
p8.outro("Run 'navori render' or 'navori sync' to apply.");
|
|
3291
|
+
}
|
|
3292
|
+
});
|
|
3293
|
+
var qualityGateSubCommand = defineCommand8({
|
|
3294
|
+
meta: {
|
|
3295
|
+
name: "quality-gate",
|
|
3296
|
+
description: "Set or update the quality gate commands (fast + full)"
|
|
3297
|
+
},
|
|
3298
|
+
args: {
|
|
3299
|
+
cwd: { type: "string", description: "Directory (default: cwd)" },
|
|
3300
|
+
fast: { type: "string", description: "Non-interactive: fast gate command" },
|
|
3301
|
+
full: { type: "string", description: "Non-interactive: full gate command" }
|
|
3302
|
+
},
|
|
3303
|
+
async run({ args }) {
|
|
3304
|
+
const cwd = resolve12(args.cwd ?? process.cwd());
|
|
3305
|
+
const { config, path, raw } = loadOrExit(cwd);
|
|
3306
|
+
p8.intro(brand("configure quality-gate"));
|
|
3307
|
+
let fast = args.fast;
|
|
3308
|
+
let full = args.full;
|
|
3309
|
+
if (!fast || !full) {
|
|
3310
|
+
const fastVal = await p8.text({
|
|
3311
|
+
message: "Fast gate command (runs on Stop hook)",
|
|
3312
|
+
placeholder: config.qualityGate?.fast ?? "pnpm tsc --noEmit",
|
|
3313
|
+
defaultValue: config.qualityGate?.fast ?? ""
|
|
3314
|
+
});
|
|
3315
|
+
if (p8.isCancel(fastVal)) {
|
|
3316
|
+
p8.cancel("Cancelled");
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
fast = fastVal.trim();
|
|
3320
|
+
const fullVal = await p8.text({
|
|
3321
|
+
message: "Full gate command (runs before close session)",
|
|
3322
|
+
placeholder: config.qualityGate?.full ?? fast,
|
|
3323
|
+
defaultValue: config.qualityGate?.full ?? fast
|
|
3324
|
+
});
|
|
3325
|
+
if (p8.isCancel(fullVal)) {
|
|
3326
|
+
p8.cancel("Cancelled");
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
3329
|
+
full = fullVal.trim();
|
|
3330
|
+
}
|
|
3331
|
+
if (!fast || !full) {
|
|
3332
|
+
p8.cancel("Both fast and full commands are required");
|
|
3333
|
+
return;
|
|
3334
|
+
}
|
|
3335
|
+
raw.qualityGate = { fast, full };
|
|
3336
|
+
persist(path, raw);
|
|
3337
|
+
p8.log.success(`qualityGate updated`);
|
|
3338
|
+
p8.outro("Done");
|
|
3339
|
+
}
|
|
3340
|
+
});
|
|
3341
|
+
var languageSubCommand = defineCommand8({
|
|
3342
|
+
meta: {
|
|
3343
|
+
name: "language",
|
|
3344
|
+
description: "Switch the language of managed Core assets (es / en)"
|
|
3345
|
+
},
|
|
3346
|
+
args: {
|
|
3347
|
+
cwd: { type: "string", description: "Directory (default: cwd)" },
|
|
3348
|
+
value: { type: "positional", description: "es | en", required: false }
|
|
3349
|
+
},
|
|
3350
|
+
async run({ args }) {
|
|
3351
|
+
const cwd = resolve12(args.cwd ?? process.cwd());
|
|
3352
|
+
const { config, path, raw } = loadOrExit(cwd);
|
|
3353
|
+
p8.intro(brand("configure language"));
|
|
3354
|
+
let value = args.value;
|
|
3355
|
+
if (!value) {
|
|
3356
|
+
const choice = await p8.select({
|
|
3357
|
+
message: "Language for managed Core assets",
|
|
3358
|
+
options: [
|
|
3359
|
+
{ value: "es", label: "Espa\xF1ol (default \u2014 full coverage)" },
|
|
3360
|
+
{ value: "en", label: "English (limited \u2014 falls back to es)" }
|
|
3361
|
+
],
|
|
3362
|
+
initialValue: config.language
|
|
3363
|
+
});
|
|
3364
|
+
if (p8.isCancel(choice)) {
|
|
3365
|
+
p8.cancel("Cancelled");
|
|
3366
|
+
return;
|
|
3367
|
+
}
|
|
3368
|
+
value = choice;
|
|
3369
|
+
}
|
|
3370
|
+
if (value !== "es" && value !== "en") {
|
|
3371
|
+
p8.cancel(`Invalid language '${value}'. Must be 'es' or 'en'.`);
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
raw.language = value;
|
|
3375
|
+
persist(path, raw);
|
|
3376
|
+
p8.log.success(`language \u2192 ${value}`);
|
|
3377
|
+
p8.outro("Run 'navori render' to re-render managed blocks in the new language.");
|
|
3378
|
+
}
|
|
3379
|
+
});
|
|
3380
|
+
var enginesSubCommand = defineCommand8({
|
|
3381
|
+
meta: {
|
|
3382
|
+
name: "engines",
|
|
3383
|
+
description: "Add or remove target engines (claude / agents-md / cursor / copilot)"
|
|
3384
|
+
},
|
|
3385
|
+
args: {
|
|
3386
|
+
cwd: { type: "string", description: "Directory (default: cwd)" }
|
|
3387
|
+
},
|
|
3388
|
+
async run({ args }) {
|
|
3389
|
+
const cwd = resolve12(args.cwd ?? process.cwd());
|
|
3390
|
+
const { config, path, raw } = loadOrExit(cwd);
|
|
3391
|
+
p8.intro(brand("configure engines"));
|
|
3392
|
+
const selected = await p8.multiselect({
|
|
3393
|
+
message: "Engines to target",
|
|
3394
|
+
options: ENGINE_OPTIONS2,
|
|
3395
|
+
required: true,
|
|
3396
|
+
initialValues: config.engines
|
|
3397
|
+
});
|
|
3398
|
+
if (p8.isCancel(selected)) {
|
|
3399
|
+
p8.cancel("Cancelled");
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
raw.engines = selected;
|
|
3403
|
+
persist(path, raw);
|
|
3404
|
+
p8.log.success(`engines \u2192 ${selected.join(", ")}`);
|
|
3405
|
+
p8.outro("Done");
|
|
3406
|
+
}
|
|
3407
|
+
});
|
|
3408
|
+
var workspaceSubCommand = defineCommand8({
|
|
3409
|
+
meta: {
|
|
3410
|
+
name: "workspace",
|
|
3411
|
+
description: "Associate this repo with a workspace (or remove the association)"
|
|
3412
|
+
},
|
|
3413
|
+
args: {
|
|
3414
|
+
cwd: { type: "string", description: "Directory (default: cwd)" },
|
|
3415
|
+
value: { type: "positional", description: "Workspace name (empty to remove)", required: false },
|
|
3416
|
+
yes: { type: "boolean", description: "Skip confirmation when removing" }
|
|
3417
|
+
},
|
|
3418
|
+
async run({ args }) {
|
|
3419
|
+
const cwd = resolve12(args.cwd ?? process.cwd());
|
|
3420
|
+
const { path, raw } = loadOrExit(cwd);
|
|
3421
|
+
const value = args.value?.trim();
|
|
3422
|
+
p8.intro(brand("configure workspace"));
|
|
3423
|
+
if (!value) {
|
|
3424
|
+
const currentWorkspace = raw.workspace;
|
|
3425
|
+
if (!currentWorkspace) {
|
|
3426
|
+
p8.outro("No workspace associated. Nothing to remove.");
|
|
3427
|
+
return;
|
|
3428
|
+
}
|
|
3429
|
+
if (!args.yes) {
|
|
3430
|
+
const ok = await p8.confirm({
|
|
3431
|
+
message: `Remove workspace association '${currentWorkspace}'? Plugins inherited from the workspace defaults will no longer be applied on next render.`,
|
|
3432
|
+
initialValue: false
|
|
3433
|
+
});
|
|
3434
|
+
if (p8.isCancel(ok) || !ok) {
|
|
3435
|
+
p8.cancel("Aborted");
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
delete raw.workspace;
|
|
3440
|
+
persist(path, raw);
|
|
3441
|
+
p8.log.success("Workspace association removed");
|
|
3442
|
+
p8.outro("Run 'navori render' to apply.");
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
raw.workspace = value;
|
|
3446
|
+
persist(path, raw);
|
|
3447
|
+
p8.log.success(`workspace \u2192 ${value}`);
|
|
3448
|
+
p8.outro("Run 'navori render' to apply.");
|
|
3449
|
+
}
|
|
3450
|
+
});
|
|
3451
|
+
var configureCommand = defineCommand8({
|
|
3452
|
+
meta: {
|
|
3453
|
+
name: "configure",
|
|
3454
|
+
description: "Modify navori.config.json sections after init"
|
|
3455
|
+
},
|
|
3456
|
+
subCommands: {
|
|
3457
|
+
plugins: pluginsSubCommand,
|
|
3458
|
+
"quality-gate": qualityGateSubCommand,
|
|
3459
|
+
language: languageSubCommand,
|
|
3460
|
+
engines: enginesSubCommand,
|
|
3461
|
+
workspace: workspaceSubCommand
|
|
3462
|
+
}
|
|
3463
|
+
});
|
|
3464
|
+
|
|
3465
|
+
// src/commands/update.ts
|
|
3466
|
+
import { defineCommand as defineCommand9 } from "citty";
|
|
3467
|
+
import * as p9 from "@clack/prompts";
|
|
3468
|
+
import { existsSync as existsSync18, readFileSync as readFileSync14 } from "fs";
|
|
3469
|
+
import { resolve as resolve13 } from "path";
|
|
3470
|
+
function diffConfig(current, detected) {
|
|
3471
|
+
const out = [];
|
|
3472
|
+
if (current.preset !== detected.suggestedPreset && detected.suggestedPreset !== "custom") {
|
|
3473
|
+
out.push({ field: "preset", before: current.preset, after: detected.suggestedPreset });
|
|
3474
|
+
}
|
|
3475
|
+
if (detected.qualityGate) {
|
|
3476
|
+
const beforeFast = current.qualityGate?.fast ?? "(none)";
|
|
3477
|
+
const beforeFull = current.qualityGate?.full ?? "(none)";
|
|
3478
|
+
if (beforeFast !== detected.qualityGate.fast) {
|
|
3479
|
+
out.push({ field: "qualityGate.fast", before: beforeFast, after: detected.qualityGate.fast });
|
|
3480
|
+
}
|
|
3481
|
+
if (beforeFull !== detected.qualityGate.full) {
|
|
3482
|
+
out.push({ field: "qualityGate.full", before: beforeFull, after: detected.qualityGate.full });
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
if (detected.branchBase && current.branchBase !== detected.branchBase) {
|
|
3486
|
+
out.push({ field: "branchBase", before: current.branchBase, after: detected.branchBase });
|
|
3487
|
+
}
|
|
3488
|
+
const currentEngines = new Set(current.engines);
|
|
3489
|
+
const newlyDetected = detected.existingEngines.filter((e) => !currentEngines.has(e));
|
|
3490
|
+
if (newlyDetected.length > 0) {
|
|
3491
|
+
out.push({
|
|
3492
|
+
field: "engines",
|
|
3493
|
+
before: current.engines.join(", "),
|
|
3494
|
+
after: [...current.engines, ...newlyDetected].join(", ")
|
|
3495
|
+
});
|
|
3496
|
+
}
|
|
3497
|
+
return out;
|
|
3498
|
+
}
|
|
3499
|
+
function applyDiffs(raw, detected, diffs) {
|
|
3500
|
+
for (const d of diffs) {
|
|
3501
|
+
if (d.field === "preset") {
|
|
3502
|
+
raw.preset = detected.suggestedPreset;
|
|
3503
|
+
} else if (d.field === "qualityGate.fast" || d.field === "qualityGate.full") {
|
|
3504
|
+
raw.qualityGate = detected.qualityGate ?? raw.qualityGate;
|
|
3505
|
+
} else if (d.field === "branchBase") {
|
|
3506
|
+
raw.branchBase = detected.branchBase;
|
|
3507
|
+
} else if (d.field === "engines") {
|
|
3508
|
+
const currentEngines = new Set(raw.engines ?? []);
|
|
3509
|
+
for (const e of detected.existingEngines) currentEngines.add(e);
|
|
3510
|
+
raw.engines = [...currentEngines];
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
var updateCommand = defineCommand9({
|
|
3515
|
+
meta: {
|
|
3516
|
+
name: "update",
|
|
3517
|
+
description: "Re-detect the repo, refresh config and run sync (one shot 'bring me up to date')"
|
|
3518
|
+
},
|
|
3519
|
+
args: {
|
|
3520
|
+
cwd: { type: "string", description: "Directory (default: cwd)" },
|
|
3521
|
+
yes: { type: "boolean", description: "Apply detected diffs and sync without prompting" },
|
|
3522
|
+
"dry-run": { type: "boolean", description: "Show what would change, do not write" },
|
|
3523
|
+
"config-only": { type: "boolean", description: "Update config but skip the sync step" }
|
|
3524
|
+
},
|
|
3525
|
+
async run({ args }) {
|
|
3526
|
+
const cwd = resolve13(args.cwd ?? process.cwd());
|
|
3527
|
+
const configPath = `${cwd}/navori.config.json`;
|
|
3528
|
+
p9.intro(brand("update"));
|
|
3529
|
+
if (!existsSync18(cwd)) {
|
|
3530
|
+
p9.cancel(`Directory not found: ${cwd}`);
|
|
3531
|
+
process.exit(1);
|
|
3532
|
+
}
|
|
3533
|
+
if (!existsSync18(configPath)) {
|
|
3534
|
+
p9.cancel(`No navori.config.json at ${configPath}. Run 'navori init' first.`);
|
|
3535
|
+
process.exit(1);
|
|
3536
|
+
}
|
|
3537
|
+
const config = readConfig(configPath);
|
|
3538
|
+
const detected = detectProject(cwd);
|
|
3539
|
+
const diffs = diffConfig(config, detected);
|
|
3540
|
+
const claudeMd = existsSync18(`${cwd}/CLAUDE.md`) ? readFileSync14(`${cwd}/CLAUDE.md`, "utf-8") : "";
|
|
3541
|
+
const plan = computeRenderPlan(claudeMd, config);
|
|
3542
|
+
if (diffs.length === 0 && !plan.changed && plan.updatesAvailable.length === 0) {
|
|
3543
|
+
p9.outro("Up to date \u2014 nothing to update");
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
if (diffs.length > 0) {
|
|
3547
|
+
const lines = diffs.map(
|
|
3548
|
+
(d) => ` ${color.yellow(sym.updated)} ${accent(d.field)}${dim(":")} ${color.red(d.before)} ${dim("\u2192")} ${color.green(d.after)}`
|
|
3549
|
+
);
|
|
3550
|
+
p9.log.info(`Config drift detected (${diffs.length}):
|
|
3551
|
+
${lines.join("\n")}`);
|
|
3552
|
+
} else {
|
|
3553
|
+
p9.log.info("Config is in sync with the repo");
|
|
3554
|
+
}
|
|
3555
|
+
if (plan.updatesAvailable.length > 0) {
|
|
3556
|
+
const lines = plan.updatesAvailable.map(
|
|
3557
|
+
(u) => ` ${color.cyan(sym.update)} ${u.id} ${dim(`(${u.source} ${u.fromVersion} \u2192 ${u.toVersion})`)}`
|
|
3558
|
+
);
|
|
3559
|
+
p9.log.info(`Managed block updates available (${plan.updatesAvailable.length}):
|
|
3560
|
+
${lines.join("\n")}`);
|
|
3561
|
+
}
|
|
3562
|
+
const conflicts = plan.entries.filter((e) => e.status === "user-modified-skipped");
|
|
3563
|
+
if (conflicts.length > 0) {
|
|
3564
|
+
p9.log.warn(`${conflicts.length} conflict(s) in managed blocks \u2014 sync will need a decision`);
|
|
3565
|
+
}
|
|
3566
|
+
if (args["dry-run"]) {
|
|
3567
|
+
p9.outro("Dry-run complete (no files written)");
|
|
3568
|
+
return;
|
|
3569
|
+
}
|
|
3570
|
+
if (!args.yes && diffs.length > 0) {
|
|
3571
|
+
const ok = await p9.confirm({
|
|
3572
|
+
message: `Apply ${diffs.length} config update${diffs.length === 1 ? "" : "s"}?`,
|
|
3573
|
+
initialValue: true
|
|
3574
|
+
});
|
|
3575
|
+
if (p9.isCancel(ok) || !ok) {
|
|
3576
|
+
p9.cancel("Aborted");
|
|
3577
|
+
return;
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
if (diffs.length > 0) {
|
|
3581
|
+
const raw = JSON.parse(readFileSync14(configPath, "utf-8"));
|
|
3582
|
+
delete raw.$schema;
|
|
3583
|
+
applyDiffs(raw, detected, diffs);
|
|
3584
|
+
writeConfig(configPath, raw);
|
|
3585
|
+
p9.log.success(`Updated ${configPath}`);
|
|
3586
|
+
}
|
|
3587
|
+
if (args["config-only"]) {
|
|
3588
|
+
p9.outro("Config updated. Run 'navori sync' when ready to refresh CLAUDE.md.");
|
|
3589
|
+
return;
|
|
3590
|
+
}
|
|
3591
|
+
if (plan.changed || plan.updatesAvailable.length > 0 || diffs.length > 0) {
|
|
3592
|
+
const freshConfig = readConfig(configPath);
|
|
3593
|
+
const claudeMdNow = existsSync18(`${cwd}/CLAUDE.md`) ? readFileSync14(`${cwd}/CLAUDE.md`, "utf-8") : "";
|
|
3594
|
+
const freshPlan = computeRenderPlan(claudeMdNow, freshConfig);
|
|
3595
|
+
const fresheConflicts = freshPlan.entries.filter((e) => e.status === "user-modified-skipped");
|
|
3596
|
+
if (fresheConflicts.length > 0 && !args.yes) {
|
|
3597
|
+
p9.log.warn(
|
|
3598
|
+
`${fresheConflicts.length} conflict(s) detected \u2014 run 'navori sync' to resolve interactively`
|
|
3599
|
+
);
|
|
3600
|
+
p9.outro("Done (config updated, sync deferred due to conflicts)");
|
|
3601
|
+
return;
|
|
3602
|
+
}
|
|
3603
|
+
if (freshPlan.changed) {
|
|
3604
|
+
if (existsSync18(`${cwd}/CLAUDE.md`)) {
|
|
3605
|
+
const handle = createBackup(cwd, ["CLAUDE.md"]);
|
|
3606
|
+
purgeOldBackups();
|
|
3607
|
+
p9.log.message(`Backup: ${handle.path}`);
|
|
3608
|
+
}
|
|
3609
|
+
writeFileAtomic(`${cwd}/CLAUDE.md`, freshPlan.next);
|
|
3610
|
+
p9.log.success(`Re-rendered ${cwd}/CLAUDE.md`);
|
|
3611
|
+
} else {
|
|
3612
|
+
p9.log.info("No re-render needed");
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
p9.outro("Done");
|
|
3616
|
+
}
|
|
3617
|
+
});
|
|
3618
|
+
|
|
3619
|
+
// src/commands/backup.ts
|
|
3620
|
+
import { defineCommand as defineCommand10 } from "citty";
|
|
3621
|
+
import * as p10 from "@clack/prompts";
|
|
3622
|
+
import { existsSync as existsSync19, readdirSync as readdirSync7, statSync as statSync8, copyFileSync as copyFileSync4, mkdirSync as mkdirSync5 } from "fs";
|
|
3623
|
+
import { join as join10, relative as relative2, resolve as resolve14, dirname as dirname5 } from "path";
|
|
3624
|
+
function listBackups() {
|
|
3625
|
+
const root = backupRoot();
|
|
3626
|
+
if (!existsSync19(root)) return [];
|
|
3627
|
+
const entries = [];
|
|
3628
|
+
for (const name of readdirSync7(root)) {
|
|
3629
|
+
const full = join10(root, name);
|
|
3630
|
+
try {
|
|
3631
|
+
const stat = statSync8(full);
|
|
3632
|
+
if (!stat.isDirectory()) continue;
|
|
3633
|
+
const files = collectFiles(full, full);
|
|
3634
|
+
entries.push({ timestamp: name, path: full, files, mtimeMs: stat.mtimeMs });
|
|
3635
|
+
} catch {
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
3639
|
+
return entries;
|
|
3640
|
+
}
|
|
3641
|
+
function collectFiles(root, dir) {
|
|
3642
|
+
const out = [];
|
|
3643
|
+
for (const entry of readdirSync7(dir)) {
|
|
3644
|
+
const full = join10(dir, entry);
|
|
3645
|
+
try {
|
|
3646
|
+
const stat = statSync8(full);
|
|
3647
|
+
if (stat.isDirectory()) {
|
|
3648
|
+
out.push(...collectFiles(root, full));
|
|
3649
|
+
} else if (stat.isFile()) {
|
|
3650
|
+
out.push(relative2(root, full));
|
|
3651
|
+
}
|
|
3652
|
+
} catch {
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
return out;
|
|
3656
|
+
}
|
|
3657
|
+
var listSubCommand2 = defineCommand10({
|
|
3658
|
+
meta: {
|
|
3659
|
+
name: "list",
|
|
3660
|
+
description: "List available backups in ~/.navori/backups/"
|
|
3661
|
+
},
|
|
3662
|
+
args: {
|
|
3663
|
+
json: { type: "boolean", description: "Output as JSON" },
|
|
3664
|
+
limit: { type: "string", description: "Show only the N most recent (default: 20)" }
|
|
3665
|
+
},
|
|
3666
|
+
run({ args }) {
|
|
3667
|
+
const backups = listBackups();
|
|
3668
|
+
const limit = args.limit ? Number.parseInt(args.limit, 10) : 20;
|
|
3669
|
+
const truncated = backups.slice(0, limit);
|
|
3670
|
+
if (args.json) {
|
|
3671
|
+
console.log(JSON.stringify({ backups: truncated, totalAvailable: backups.length }, null, 2));
|
|
3672
|
+
return;
|
|
3673
|
+
}
|
|
3674
|
+
p10.intro(brand("backup list"));
|
|
3675
|
+
if (backups.length === 0) {
|
|
3676
|
+
p10.log.info("No backups found. They are created automatically before each 'sync' or 'render' that modifies files.");
|
|
3677
|
+
p10.outro(dim("Done"));
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
const lines = [];
|
|
3681
|
+
lines.push(dim(`${backups.length} backup(s) total. Showing ${truncated.length}:`));
|
|
3682
|
+
for (const b of truncated) {
|
|
3683
|
+
const date = new Date(b.mtimeMs);
|
|
3684
|
+
const ago = dim(humanAge(b.mtimeMs));
|
|
3685
|
+
lines.push(` ${color.cyan(sym.bullet)} ${accent(b.timestamp)} ${dim(date.toISOString())} ${ago}`);
|
|
3686
|
+
for (const f of b.files) {
|
|
3687
|
+
lines.push(` ${dim(sym.bullet)} ${dim(f)}`);
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
if (backups.length > truncated.length) {
|
|
3691
|
+
lines.push(dim(` ... ${backups.length - truncated.length} more (use --limit to show)`));
|
|
3692
|
+
}
|
|
3693
|
+
p10.log.message(lines.join("\n"));
|
|
3694
|
+
p10.outro(dim("Done"));
|
|
3695
|
+
}
|
|
3696
|
+
});
|
|
3697
|
+
var restoreSubCommand = defineCommand10({
|
|
3698
|
+
meta: {
|
|
3699
|
+
name: "restore",
|
|
3700
|
+
description: "Restore files from a backup snapshot to the current directory"
|
|
3701
|
+
},
|
|
3702
|
+
args: {
|
|
3703
|
+
timestamp: { type: "positional", description: "Backup timestamp (from 'backup list')", required: true },
|
|
3704
|
+
cwd: { type: "string", description: "Target directory (default: current)" },
|
|
3705
|
+
yes: { type: "boolean", description: "Skip confirmation" }
|
|
3706
|
+
},
|
|
3707
|
+
async run({ args }) {
|
|
3708
|
+
const ts = args.timestamp;
|
|
3709
|
+
const cwd = resolve14(args.cwd ?? process.cwd());
|
|
3710
|
+
const backupDir = join10(backupRoot(), ts);
|
|
3711
|
+
p10.intro(brand(`backup restore ${accent(ts)}`));
|
|
3712
|
+
if (!existsSync19(backupDir)) {
|
|
3713
|
+
p10.cancel(`Backup not found: ${backupDir}`);
|
|
3714
|
+
process.exit(1);
|
|
3715
|
+
}
|
|
3716
|
+
const files = collectFiles(backupDir, backupDir);
|
|
3717
|
+
if (files.length === 0) {
|
|
3718
|
+
p10.cancel(`Backup is empty: ${backupDir}`);
|
|
3719
|
+
process.exit(1);
|
|
3720
|
+
}
|
|
3721
|
+
p10.log.message(`Will restore ${files.length} file(s) from ${backupDir} into ${cwd}:`);
|
|
3722
|
+
for (const f of files) p10.log.message(` \xB7 ${f}`);
|
|
3723
|
+
if (!args.yes) {
|
|
3724
|
+
const ok = await p10.confirm({
|
|
3725
|
+
message: "Existing files will be overwritten. Proceed?",
|
|
3726
|
+
initialValue: false
|
|
3727
|
+
});
|
|
3728
|
+
if (p10.isCancel(ok) || !ok) {
|
|
3729
|
+
p10.cancel("Aborted");
|
|
3730
|
+
return;
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
for (const rel of files) {
|
|
3734
|
+
const src = join10(backupDir, rel);
|
|
3735
|
+
const dest = join10(cwd, rel);
|
|
3736
|
+
mkdirSync5(dirname5(dest), { recursive: true });
|
|
3737
|
+
copyFileSync4(src, dest);
|
|
3738
|
+
}
|
|
3739
|
+
p10.outro(`Restored ${files.length} file(s)`);
|
|
3740
|
+
}
|
|
3741
|
+
});
|
|
3742
|
+
function humanAge(mtimeMs) {
|
|
3743
|
+
const diffMs = Date.now() - mtimeMs;
|
|
3744
|
+
const minutes = Math.floor(diffMs / 6e4);
|
|
3745
|
+
if (minutes < 1) return "(just now)";
|
|
3746
|
+
if (minutes < 60) return `(${minutes} min ago)`;
|
|
3747
|
+
const hours = Math.floor(minutes / 60);
|
|
3748
|
+
if (hours < 24) return `(${hours} h ago)`;
|
|
3749
|
+
const days = Math.floor(hours / 24);
|
|
3750
|
+
return `(${days} d ago)`;
|
|
3751
|
+
}
|
|
3752
|
+
var backupCommand = defineCommand10({
|
|
3753
|
+
meta: {
|
|
3754
|
+
name: "backup",
|
|
3755
|
+
description: "List and restore navori backups"
|
|
3756
|
+
},
|
|
3757
|
+
subCommands: {
|
|
3758
|
+
list: listSubCommand2,
|
|
3759
|
+
restore: restoreSubCommand
|
|
3760
|
+
}
|
|
3761
|
+
});
|
|
3762
|
+
|
|
3763
|
+
// src/commands/migrations.ts
|
|
3764
|
+
import { defineCommand as defineCommand11 } from "citty";
|
|
3765
|
+
import * as p11 from "@clack/prompts";
|
|
3766
|
+
import { existsSync as existsSync20, readdirSync as readdirSync8, statSync as statSync9, copyFileSync as copyFileSync5, mkdirSync as mkdirSync6 } from "fs";
|
|
3767
|
+
import { join as join11, relative as relative3, resolve as resolve15, dirname as dirname6 } from "path";
|
|
3768
|
+
function listMigrations() {
|
|
3769
|
+
const root = migrationsRoot();
|
|
3770
|
+
if (!existsSync20(root)) return [];
|
|
3771
|
+
const entries = [];
|
|
3772
|
+
for (const ts of readdirSync8(root)) {
|
|
3773
|
+
const tsDir = join11(root, ts);
|
|
3774
|
+
try {
|
|
3775
|
+
const stat = statSync9(tsDir);
|
|
3776
|
+
if (!stat.isDirectory()) continue;
|
|
3777
|
+
for (const repoName of readdirSync8(tsDir)) {
|
|
3778
|
+
const repoDir = join11(tsDir, repoName);
|
|
3779
|
+
try {
|
|
3780
|
+
const repoStat = statSync9(repoDir);
|
|
3781
|
+
if (!repoStat.isDirectory()) continue;
|
|
3782
|
+
const files = collectFiles2(repoDir, repoDir);
|
|
3783
|
+
entries.push({ timestamp: ts, repoName, path: repoDir, files, mtimeMs: repoStat.mtimeMs });
|
|
3784
|
+
} catch {
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
} catch {
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
3791
|
+
return entries;
|
|
3792
|
+
}
|
|
3793
|
+
function collectFiles2(root, dir) {
|
|
3794
|
+
const out = [];
|
|
3795
|
+
for (const entry of readdirSync8(dir)) {
|
|
3796
|
+
const full = join11(dir, entry);
|
|
3797
|
+
try {
|
|
3798
|
+
const stat = statSync9(full);
|
|
3799
|
+
if (stat.isDirectory()) {
|
|
3800
|
+
out.push(...collectFiles2(root, full));
|
|
3801
|
+
} else if (stat.isFile()) {
|
|
3802
|
+
out.push(relative3(root, full));
|
|
3803
|
+
}
|
|
3804
|
+
} catch {
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
return out;
|
|
3808
|
+
}
|
|
3809
|
+
var listSubCommand3 = defineCommand11({
|
|
3810
|
+
meta: {
|
|
3811
|
+
name: "list",
|
|
3812
|
+
description: "List 'init --replace' migrations stored in ~/.navori/migrations/"
|
|
3813
|
+
},
|
|
3814
|
+
args: {
|
|
3815
|
+
json: { type: "boolean", description: "Output as JSON" },
|
|
3816
|
+
limit: { type: "string", description: "Show only the N most recent (default: 20)" }
|
|
3817
|
+
},
|
|
3818
|
+
run({ args }) {
|
|
3819
|
+
const migrations = listMigrations();
|
|
3820
|
+
const limit = args.limit ? Number.parseInt(args.limit, 10) : 20;
|
|
3821
|
+
const truncated = migrations.slice(0, limit);
|
|
3822
|
+
if (args.json) {
|
|
3823
|
+
console.log(JSON.stringify({ migrations: truncated, totalAvailable: migrations.length }, null, 2));
|
|
3824
|
+
return;
|
|
3825
|
+
}
|
|
3826
|
+
p11.intro(brand("migrations list"));
|
|
3827
|
+
if (migrations.length === 0) {
|
|
3828
|
+
p11.log.info("No migrations found. They are created when 'init --replace' is used to start fresh on a repo with existing Claude infrastructure.");
|
|
3829
|
+
p11.outro(dim("Done"));
|
|
3830
|
+
return;
|
|
3831
|
+
}
|
|
3832
|
+
const lines = [];
|
|
3833
|
+
lines.push(dim(`${migrations.length} migration(s) total. Showing ${truncated.length}:`));
|
|
3834
|
+
for (const m of truncated) {
|
|
3835
|
+
const date = new Date(m.mtimeMs);
|
|
3836
|
+
lines.push(
|
|
3837
|
+
` ${color.cyan(sym.bullet)} ${accent(m.timestamp)} ${dim(`repo='${m.repoName}'`)} ${dim(date.toISOString())}`
|
|
3838
|
+
);
|
|
3839
|
+
for (const f of m.files) {
|
|
3840
|
+
lines.push(` ${dim(sym.bullet)} ${dim(f)}`);
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
if (migrations.length > truncated.length) {
|
|
3844
|
+
lines.push(dim(` ... ${migrations.length - truncated.length} more (use --limit to show)`));
|
|
3845
|
+
}
|
|
3846
|
+
p11.log.message(lines.join("\n"));
|
|
3847
|
+
p11.outro(dim("Done"));
|
|
3848
|
+
}
|
|
3849
|
+
});
|
|
3850
|
+
var restoreSubCommand2 = defineCommand11({
|
|
3851
|
+
meta: {
|
|
3852
|
+
name: "restore",
|
|
3853
|
+
description: "Restore an 'init --replace' migration back to the original repo"
|
|
3854
|
+
},
|
|
3855
|
+
args: {
|
|
3856
|
+
timestamp: { type: "positional", description: "Migration timestamp", required: true },
|
|
3857
|
+
repo: { type: "positional", description: "Repo name (from 'migrations list')", required: true },
|
|
3858
|
+
cwd: { type: "string", description: "Target directory (default: current)" },
|
|
3859
|
+
yes: { type: "boolean", description: "Skip confirmation" }
|
|
3860
|
+
},
|
|
3861
|
+
async run({ args }) {
|
|
3862
|
+
const ts = args.timestamp;
|
|
3863
|
+
const repoName = args.repo;
|
|
3864
|
+
const cwd = resolve15(args.cwd ?? process.cwd());
|
|
3865
|
+
const migrationDir = join11(migrationsRoot(), ts, repoName);
|
|
3866
|
+
p11.intro(brand(`migrations restore ${accent(`${ts}/${repoName}`)}`));
|
|
3867
|
+
if (!existsSync20(migrationDir)) {
|
|
3868
|
+
p11.cancel(`Migration not found: ${migrationDir}`);
|
|
3869
|
+
process.exit(1);
|
|
3870
|
+
}
|
|
3871
|
+
const files = collectFiles2(migrationDir, migrationDir);
|
|
3872
|
+
if (files.length === 0) {
|
|
3873
|
+
p11.cancel(`Migration is empty: ${migrationDir}`);
|
|
3874
|
+
process.exit(1);
|
|
3875
|
+
}
|
|
3876
|
+
p11.log.message(`Will restore ${files.length} file(s) from ${migrationDir} into ${cwd}:`);
|
|
3877
|
+
for (const f of files.slice(0, 10)) p11.log.message(` \xB7 ${f}`);
|
|
3878
|
+
if (files.length > 10) p11.log.message(` ... ${files.length - 10} more`);
|
|
3879
|
+
if (!args.yes) {
|
|
3880
|
+
const ok = await p11.confirm({
|
|
3881
|
+
message: "Existing files will be OVERWRITTEN by the migration's snapshot. Proceed?",
|
|
3882
|
+
initialValue: false
|
|
3883
|
+
});
|
|
3884
|
+
if (p11.isCancel(ok) || !ok) {
|
|
3885
|
+
p11.cancel("Aborted");
|
|
3886
|
+
return;
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
for (const rel of files) {
|
|
3890
|
+
const src = join11(migrationDir, rel);
|
|
3891
|
+
const dest = join11(cwd, rel);
|
|
3892
|
+
mkdirSync6(dirname6(dest), { recursive: true });
|
|
3893
|
+
copyFileSync5(src, dest);
|
|
3894
|
+
}
|
|
3895
|
+
p11.outro(`Restored ${files.length} file(s)`);
|
|
3896
|
+
}
|
|
3897
|
+
});
|
|
3898
|
+
var migrationsCommand = defineCommand11({
|
|
3899
|
+
meta: {
|
|
3900
|
+
name: "migrations",
|
|
3901
|
+
description: "List and restore 'init --replace' migrations"
|
|
3902
|
+
},
|
|
3903
|
+
subCommands: {
|
|
3904
|
+
list: listSubCommand3,
|
|
3905
|
+
restore: restoreSubCommand2
|
|
3906
|
+
}
|
|
3907
|
+
});
|
|
3908
|
+
|
|
3909
|
+
// src/index.ts
|
|
3910
|
+
function readVersion() {
|
|
3911
|
+
const here = dirname7(fileURLToPath2(import.meta.url));
|
|
3912
|
+
for (const candidate of [
|
|
3913
|
+
resolve16(here, "..", "package.json"),
|
|
3914
|
+
resolve16(here, "package.json")
|
|
3915
|
+
]) {
|
|
3916
|
+
try {
|
|
3917
|
+
const pkg = JSON.parse(readFileSync15(candidate, "utf-8"));
|
|
3918
|
+
if (pkg.version) return pkg.version;
|
|
3919
|
+
} catch {
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
return "0.0.0";
|
|
3923
|
+
}
|
|
3924
|
+
var main = defineCommand12({
|
|
3925
|
+
meta: {
|
|
3926
|
+
name: "navori",
|
|
3927
|
+
version: readVersion(),
|
|
3928
|
+
description: "Multi-agent harness + SDD scaffolder"
|
|
3929
|
+
},
|
|
3930
|
+
subCommands: {
|
|
3931
|
+
init: initCommand,
|
|
3932
|
+
add: addCommand,
|
|
3933
|
+
configure: configureCommand,
|
|
3934
|
+
update: updateCommand,
|
|
3935
|
+
render: renderCommand,
|
|
3936
|
+
sync: syncCommand,
|
|
3937
|
+
doctor: doctorCommand,
|
|
3938
|
+
workspace: workspaceCommand,
|
|
3939
|
+
ticket: ticketCommand,
|
|
3940
|
+
backup: backupCommand,
|
|
3941
|
+
migrations: migrationsCommand
|
|
3942
|
+
}
|
|
3943
|
+
});
|
|
3944
|
+
runMain(main);
|