@crouton-kit/crouter 0.3.47 → 0.3.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +5 -3
  3. package/dist/clients/attach/attach-cmd.js +425 -425
  4. package/dist/clients/attach/titled-editor.js +6 -3
  5. package/dist/commands/human/shared.js +7 -3
  6. package/dist/commands/node.js +96 -11
  7. package/dist/commands/profile/default.d.ts +2 -0
  8. package/dist/commands/profile/default.js +143 -0
  9. package/dist/commands/profile/new.js +3 -3
  10. package/dist/commands/profile/project.d.ts +2 -0
  11. package/dist/commands/profile/project.js +97 -0
  12. package/dist/commands/profile/show.js +1 -1
  13. package/dist/commands/profile.js +10 -11
  14. package/dist/commands/sys/__tests__/sync-import.test.js +89 -1
  15. package/dist/commands/sys/sync.js +242 -12
  16. package/dist/core/__tests__/broker-sdk-wiring.test.js +7 -5
  17. package/dist/core/__tests__/context-intro.test.js +12 -19
  18. package/dist/core/profiles/default-binding.d.ts +10 -0
  19. package/dist/core/profiles/default-binding.js +50 -0
  20. package/dist/core/profiles/select.d.ts +13 -1
  21. package/dist/core/profiles/select.js +92 -26
  22. package/dist/core/runtime/bearings.d.ts +15 -7
  23. package/dist/core/runtime/bearings.js +26 -85
  24. package/dist/core/runtime/broker.js +5 -4
  25. package/dist/core/runtime/front-door.js +11 -4
  26. package/dist/core/runtime/revive.js +2 -1
  27. package/dist/core/runtime/spawn.d.ts +4 -0
  28. package/dist/core/runtime/spawn.js +1 -1
  29. package/dist/core/substrate/on-read.js +9 -6
  30. package/dist/daemon/crtrd.js +44 -2
  31. package/dist/daemon/manage.d.ts +20 -0
  32. package/dist/daemon/manage.js +64 -2
  33. package/package.json +2 -2
  34. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/nested-context.ts +0 -327
  35. package/dist/commands/profile/add-project.d.ts +0 -1
  36. package/dist/commands/profile/add-project.js +0 -42
  37. package/dist/commands/profile/remove-project.d.ts +0 -1
  38. package/dist/commands/profile/remove-project.js +0 -42
@@ -6,11 +6,61 @@
6
6
  import { spawn } from 'node:child_process';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { mkdirSync } from 'node:fs';
9
+ import { mkdirSync, openSync, closeSync } from 'node:fs';
10
10
  import { crtrHome } from '../core/canvas/paths.js';
11
11
  import { hostExecPath } from '../core/runtime/branded-host.js';
12
12
  import { isDaemonRunning, readPidfile, isPidAlive } from './crtrd.js';
13
13
  // ---------------------------------------------------------------------------
14
+ // Daemon env sanitization
15
+ // ---------------------------------------------------------------------------
16
+ /** Env keys that must NEVER reach the daemon process. Restarting crtrd is
17
+ * overwhelmingly done from inside an agent node's own bash tool (`crtr sys
18
+ * daemon stop && start`), which inherits that node's FULL env — its identity
19
+ * (`CRTR_NODE_ID`/`CRTR_KIND`/…, the `nodeEnv()` shape in `core/runtime/
20
+ * nodes.ts`), its front-door recursion-guard flag, and any pi-engine
21
+ * resolution seam a prior test/dev session left exported. The daemon is a
22
+ * singleton supervisor, never "a node" itself, so none of this belongs in its
23
+ * env regardless of whether today's code happens to read it — and at least one
24
+ * of these IS actively read: `host.ts`'s broker-engine resolution falls back to
25
+ * `process.env['CRTR_BROKER_ENGINE']` verbatim (nodeEnv() never sets that key,
26
+ * so nothing overrides it per child launch), so a daemon that inherits a
27
+ * stale/dev override throws inside `headlessBrokerHost.launch()` on EVERY
28
+ * relaunch it ever attempts, for its whole lifetime — the 2026-07-06 diagnosis
29
+ * root cause behind 19 nodes killed with "failed to relaunch and is now dead". */
30
+ export const DAEMON_ENV_STRIP_KEYS = [
31
+ // Node identity (nodeEnv() shape) — meaningless for a process supervising
32
+ // many nodes rather than being one.
33
+ 'CRTR_NODE_ID',
34
+ 'CRTR_KIND',
35
+ 'CRTR_MODE',
36
+ 'CRTR_LIFECYCLE',
37
+ 'CRTR_NODE_CWD',
38
+ 'CRTR_CONTEXT_DIR',
39
+ 'CRTR_CYCLES',
40
+ 'CRTR_PROFILE_ID',
41
+ 'CRTR_PARENT_NODE_ID',
42
+ // Recursion-guard flag — only meaningful inside a pi engine process.
43
+ 'CRTR_FRONT_DOOR',
44
+ // Engine-resolution seams NOT overridden per child launch — the proven and
45
+ // suspected poison vectors.
46
+ 'CRTR_BROKER_ENGINE',
47
+ 'CRTR_PI_BINARY',
48
+ 'CRTR_FAULT_RETRY_PROBE',
49
+ // Generic Node.js env poisoning (a stray dev/debug flag from the restarting
50
+ // shell).
51
+ 'NODE_OPTIONS',
52
+ ];
53
+ /** A copy of `process.env` with every `DAEMON_ENV_STRIP_KEYS` entry removed.
54
+ * Deliberate global config the user actually wants the daemon to see —
55
+ * `CRTR_HOME`, `CRTR_SUBTREE`, `CRTR_LOG`, `CRTR_DEBUG`, etc. — passes through
56
+ * untouched; only the node-identity/engine-poisoning surface is stripped. */
57
+ export function sanitizedDaemonEnv() {
58
+ const env = { ...process.env };
59
+ for (const key of DAEMON_ENV_STRIP_KEYS)
60
+ delete env[key];
61
+ return env;
62
+ }
63
+ // ---------------------------------------------------------------------------
14
64
  // Entry point resolution
15
65
  // ---------------------------------------------------------------------------
16
66
  /** Resolve the absolute path to the crtrd-cli entry point.
@@ -70,10 +120,22 @@ export async function spawnDaemon() {
70
120
  // is launchd-owned, the plist's ProgramArguments must point at the branded
71
121
  // binary too — this path only covers a manual `crtr sys daemon start`.
72
122
  const entry = resolveCrtrdEntry();
123
+ // Route stdout+stderr to an append-mode file instead of discarding them
124
+ // (stdio:'ignore'). Every `[crtrd] …` diagnostic — including the one line
125
+ // that names WHY a relaunch failed (crtrd.ts's per-node supervise catch) —
126
+ // used to go to /dev/null for any manually-started daemon, the common case
127
+ // (only a launchd-owned daemon had a logging plist). Mirrors host.ts's
128
+ // broker.log pattern: one fd, both streams, append so a restart keeps history.
129
+ const errLogPath = join(crtrHome(), 'crtrd.err');
130
+ const errFd = openSync(errLogPath, 'a');
73
131
  const child = spawn(hostExecPath(), [entry], {
74
132
  detached: true,
75
- stdio: 'ignore',
133
+ stdio: ['ignore', errFd, errFd],
134
+ env: sanitizedDaemonEnv(),
76
135
  });
136
+ // The child holds its own dup of the fd; release the parent's copy so the
137
+ // launching process never leaks it.
138
+ closeSync(errFd);
77
139
  const pid = child.pid;
78
140
  if (pid === undefined) {
79
141
  throw new Error('daemon spawn did not return a pid');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.47",
3
+ "version": "0.3.49",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "license": "MIT",
49
49
  "dependencies": {
50
- "@crouton-kit/humanloop": "^0.3.22",
50
+ "@crouton-kit/humanloop": "^0.3.27",
51
51
  "@earendil-works/pi-agent-core": "0.80.2",
52
52
  "@earendil-works/pi-ai": "0.80.3",
53
53
  "@earendil-works/pi-coding-agent": "0.80.2",
@@ -1,327 +0,0 @@
1
- /**
2
- * Nested Context Loader
3
- *
4
- * Pi only loads AGENTS.md / CLAUDE.md from the cwd's ancestor chain at startup.
5
- * Anything *below* the launch dir (subproject CLAUDE.md files, .claude/rules/)
6
- * is never pulled in. This extension fills that gap:
7
- *
8
- * When the `read` tool reads a file, we walk from that file's directory up to
9
- * the session cwd and surface, appended to the read result:
10
- * 1. Each directory's CLAUDE.md / AGENTS.md (first match per dir).
11
- * 2. Each directory's .claude/rules/*.md — unconditionally if the rule has no
12
- * `paths:` frontmatter, or only when the read file matches one of its
13
- * `paths:` globs.
14
- *
15
- * Guarantees:
16
- * - No duplicates. A `seen` set tracks every absolute path already loaded,
17
- * including the files pi loaded at startup (seeded in session_start) and any
18
- * context/rule file the agent reads directly.
19
- * - Bounded. Anchored to the file's own parent chain (not the session cwd),
20
- * stops at $HOME, and skips generated/dependency dirs (node_modules, ...).
21
- *
22
- * Install: drop in ~/.pi/agent/extensions/ (global) or .pi/extensions/ (project).
23
- */
24
-
25
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
- import * as fs from "node:fs";
27
- import * as os from "node:os";
28
- import * as path from "node:path";
29
-
30
- // First-match-per-dir order mirrors pi's own loader (AGENTS.md wins over CLAUDE.md).
31
- const CONTEXT_FILENAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
32
-
33
- // Directory names we never descend into / load context from.
34
- const JUNK_DIRS = new Set([
35
- "node_modules",
36
- ".git",
37
- ".venv",
38
- "venv",
39
- "dist",
40
- "build",
41
- ".next",
42
- ".cache",
43
- ".yalc",
44
- ".sisyphus",
45
- ".crouter",
46
- ]);
47
-
48
- interface RuleFile {
49
- name: string;
50
- paths: string[];
51
- body: string;
52
- }
53
-
54
- function realpathOrSelf(p: string): string {
55
- try {
56
- return fs.realpathSync(p);
57
- } catch {
58
- return p;
59
- }
60
- }
61
-
62
- function isJunkPath(absDir: string): boolean {
63
- return absDir.split(path.sep).some((seg) => JUNK_DIRS.has(seg));
64
- }
65
-
66
- // Nearest enclosing git repo root for a path (walk up looking for `.git`).
67
- function gitRoot(p: string): string | null {
68
- let d = p;
69
- const root = path.parse(d).root;
70
- while (true) {
71
- if (fs.existsSync(path.join(d, ".git"))) return d;
72
- if (d === root) return null;
73
- const parent = path.dirname(d);
74
- if (parent === d) return null;
75
- d = parent;
76
- }
77
- }
78
-
79
- // Display paths relative to the nearest git repo root, else absolute.
80
- function disp(p: string): string {
81
- const root = gitRoot(p);
82
- if (!root) return p;
83
- const rel = path.relative(root, p);
84
- return rel === "" ? "." : rel;
85
- }
86
-
87
- // Escape a value for use inside an XML-ish attribute in the injected block.
88
- function attr(s: string): string {
89
- return s
90
- .replace(/&/g, "&")
91
- .replace(/"/g, """)
92
- .replace(/</g, "&lt;")
93
- .replace(/>/g, "&gt;");
94
- }
95
-
96
- const AUTO_CONTEXT_RE = /<auto-loaded-context(?:\s[^>]*)?>\n([\s\S]*?)\n<\/auto-loaded-context>/;
97
-
98
- function mergeAutoLoadedContext(items: string[], content: Array<{ type: string; text?: string }>) {
99
- const existingText = content.map((block) => block.text ?? "").join("\n");
100
- const fresh = items.map((item) => item.trim()).filter((item) => item !== "" && !existingText.includes(item));
101
- if (fresh.length === 0) return content;
102
- const inner = fresh.join("\n");
103
-
104
- const existingIdx = content.findIndex((block) => typeof block.text === "string" && AUTO_CONTEXT_RE.test(block.text));
105
- if (existingIdx !== -1) {
106
- return content.map((block, idx) => {
107
- if (idx !== existingIdx || typeof block.text !== "string") return block;
108
- return {
109
- ...block,
110
- text: block.text.replace(AUTO_CONTEXT_RE, (_whole, current: string) => {
111
- const trimmed = String(current).trim();
112
- return `<auto-loaded-context>\n${inner}${trimmed === "" ? "" : `\n${trimmed}`}\n</auto-loaded-context>`;
113
- }),
114
- };
115
- });
116
- }
117
-
118
- return [{ type: "text" as const, text: `<auto-loaded-context>\n${inner}\n</auto-loaded-context>` }, ...content];
119
- }
120
-
121
- function firstContextFile(dir: string): string | null {
122
- for (const name of CONTEXT_FILENAMES) {
123
- const fp = path.join(dir, name);
124
- if (fs.existsSync(fp) && fs.statSync(fp).isFile()) return fp;
125
- }
126
- return null;
127
- }
128
-
129
- function findMarkdown(dir: string): string[] {
130
- const out: string[] = [];
131
- let entries: fs.Dirent[];
132
- try {
133
- entries = fs.readdirSync(dir, { withFileTypes: true });
134
- } catch {
135
- return out;
136
- }
137
- for (const e of entries) {
138
- const fp = path.join(dir, e.name);
139
- if (e.isDirectory()) out.push(...findMarkdown(fp));
140
- else if (e.isFile() && e.name.endsWith(".md")) out.push(fp);
141
- }
142
- return out;
143
- }
144
-
145
- // Minimal frontmatter parse: pulls `name` and the `paths:` list. Avoids a YAML dep.
146
- function parseRule(filePath: string): RuleFile {
147
- let raw = "";
148
- try {
149
- raw = fs.readFileSync(filePath, "utf-8");
150
- } catch {
151
- return { name: path.basename(filePath, ".md"), paths: [], body: "" };
152
- }
153
- const fm = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
154
- if (!fm) return { name: path.basename(filePath, ".md"), paths: [], body: raw };
155
-
156
- const [, front, body] = fm;
157
- const lines = front.split("\n");
158
- let name = path.basename(filePath, ".md");
159
- const paths: string[] = [];
160
- let inPaths = false;
161
-
162
- const clean = (s: string) => s.trim().replace(/^["']|["']$/g, "");
163
- for (const line of lines) {
164
- const nameMatch = line.match(/^name:\s*(.+)$/);
165
- if (nameMatch) {
166
- name = clean(nameMatch[1]);
167
- inPaths = false;
168
- continue;
169
- }
170
- const pathsInline = line.match(/^paths:\s*(.*)$/);
171
- if (pathsInline) {
172
- const rest = pathsInline[1].trim();
173
- if (rest === "" || rest === "[]") {
174
- inPaths = true; // YAML list form follows on subsequent lines
175
- } else {
176
- // Inline form: a single glob or a comma/space-separated list,
177
- // optionally wrapped in [ ... ].
178
- inPaths = false;
179
- for (const part of rest.replace(/^\[|\]$/g, "").split(/[,]/)) {
180
- const g = clean(part);
181
- if (g) paths.push(g);
182
- }
183
- }
184
- continue;
185
- }
186
- if (inPaths) {
187
- const item = line.match(/^\s*-\s*(.+)$/);
188
- if (item) {
189
- paths.push(clean(item[1]));
190
- continue;
191
- }
192
- if (/^\S/.test(line)) inPaths = false; // dedented to next key
193
- }
194
- }
195
- return { name, paths, body: body.trim() };
196
- }
197
-
198
- function ruleMatches(absFile: string, baseDir: string, globs: string[]): boolean {
199
- if (globs.length === 0) return true; // unconditional rule
200
- const rel = path.relative(baseDir, absFile);
201
- return globs.some((g) => {
202
- try {
203
- return path.matchesGlob(rel, g) || path.matchesGlob(absFile, g);
204
- } catch {
205
- return false;
206
- }
207
- });
208
- }
209
-
210
- export default function nestedContext(pi: ExtensionAPI) {
211
- const seen = new Set<string>();
212
-
213
- // Seed `seen` with everything pi already loaded at startup: the global
214
- // agent file plus every CLAUDE.md/AGENTS.md on the cwd ancestor chain.
215
- pi.on("session_start", async (_event, ctx) => {
216
- const globalDir = path.join(os.homedir(), ".pi", "agent");
217
- const globalCtx = firstContextFile(globalDir);
218
- if (globalCtx) seen.add(realpathOrSelf(globalCtx));
219
-
220
- let dir = path.resolve(ctx.cwd);
221
- const root = path.parse(dir).root;
222
- while (true) {
223
- const cf = firstContextFile(dir);
224
- if (cf) seen.add(realpathOrSelf(cf));
225
- if (dir === root) break;
226
- const parent = path.dirname(dir);
227
- if (parent === dir) break;
228
- dir = parent;
229
- }
230
- });
231
-
232
- pi.on("tool_result", async (event, ctx) => {
233
- if (event.toolName !== "read") return;
234
- if (event.isError) return;
235
-
236
- const rawPath = (event.input as { path?: string; file_path?: string })?.path
237
- ?? (event.input as { file_path?: string })?.file_path;
238
- if (!rawPath) return;
239
-
240
- const cwd = path.resolve(ctx.cwd);
241
- // The model often passes a leading ~ ; pi's read tool expands it but
242
- // event.input keeps the raw form, so expand it ourselves before resolving.
243
- const expanded = rawPath.startsWith("~")
244
- ? path.join(os.homedir(), rawPath.slice(1))
245
- : rawPath;
246
- const absFile = realpathOrSelf(path.resolve(cwd, expanded));
247
-
248
- // Anchor to the FILE's own location, not the session cwd: walk its parent
249
- // chain up to the home directory (or filesystem root if outside home).
250
- const home = os.homedir();
251
- const fsRoot = path.parse(absFile).root;
252
-
253
- // If the agent read a context/rule file directly, mark it loaded so we
254
- // never re-inject it later, and don't append it to its own read.
255
- seen.add(absFile);
256
-
257
- type Block = { order: number; text: string };
258
- const blocks: Block[] = [];
259
-
260
- let dir = path.dirname(absFile);
261
- let depth = 0;
262
- while (true) {
263
- if (!isJunkPath(dir)) {
264
- // 1) Directory context file.
265
- const cf = firstContextFile(dir);
266
- if (cf) {
267
- const real = realpathOrSelf(cf);
268
- if (!seen.has(real)) {
269
- seen.add(real);
270
- try {
271
- const content = fs.readFileSync(cf, "utf-8").trim();
272
- blocks.push({
273
- order: depth,
274
- text:
275
- `<project-guidance src="${attr(disp(cf))}" scope="${attr(`${disp(dir)}/`)}">\n` +
276
- `${content}\n` +
277
- `</project-guidance>`,
278
- });
279
- } catch {
280
- /* unreadable; skip */
281
- }
282
- }
283
- }
284
-
285
- // 2) .claude/rules for this directory.
286
- const rulesDir = path.join(dir, ".claude", "rules");
287
- for (const rf of findMarkdown(rulesDir)) {
288
- // Skip stray context files living inside a rules dir; they are not rules.
289
- if (CONTEXT_FILENAMES.includes(path.basename(rf))) continue;
290
- const real = realpathOrSelf(rf);
291
- if (seen.has(real)) continue;
292
- const rule = parseRule(rf);
293
- if (!ruleMatches(absFile, dir, rule.paths)) continue; // may match on a later read
294
- seen.add(real);
295
- blocks.push({
296
- order: depth,
297
- text:
298
- `<rule name="${attr(rule.name)}" src="${attr(disp(rf))}">\n` +
299
- `${rule.body}\n` +
300
- `</rule>`,
301
- });
302
- }
303
- }
304
-
305
- if (dir === home || dir === fsRoot) break;
306
- const parent = path.dirname(dir);
307
- if (parent === dir) break;
308
- dir = parent;
309
- depth += 1;
310
- }
311
-
312
- if (blocks.length === 0) return;
313
-
314
- // Outermost-first, so the most specific (nearest) guidance reads last/closest
315
- // to the file content that follows it.
316
- blocks.sort((a, b) => b.order - a.order);
317
- const merged = mergeAutoLoadedContext(blocks.map((b) => b.text), event.content);
318
- if (merged === event.content) return;
319
-
320
- if (ctx.hasUI) {
321
- ctx.ui.notify(`Loaded ${blocks.length} nested context file(s)`, "info");
322
- }
323
-
324
- // Prepend or merge, so the governing guidance appears before the file output.
325
- return { content: merged };
326
- });
327
- }
@@ -1 +0,0 @@
1
- export declare const addProjectLeaf: import("../../core/command.js").LeafDef;
@@ -1,42 +0,0 @@
1
- import { defineLeaf } from '../../core/command.js';
2
- import { addProfileProject, loadProfileManifest } from '../../core/profiles/manifest.js';
3
- export const addProjectLeaf = defineLeaf({
4
- name: 'add-project',
5
- description: "add a project directory to a profile's purview",
6
- whenToUse: "a profile needs to see (memory + config from) another project directory — extend its purview after creation, including mid-session from a node already running under that profile.",
7
- help: {
8
- name: 'profile add-project',
9
- summary: "append a project directory to a profile's manifest, deduped by real path",
10
- params: [
11
- {
12
- kind: 'positional',
13
- name: 'profile',
14
- required: true,
15
- constraint: 'Exact profile id, or a unique manifest name.',
16
- },
17
- {
18
- kind: 'flag',
19
- name: 'dir',
20
- type: 'path',
21
- required: true,
22
- constraint: 'Directory to add. Must exist and be a directory; resolved to its absolute real path before storing. A path already on the manifest is a no-op, not an error.',
23
- },
24
- ],
25
- output: [
26
- { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
27
- { name: 'projects', type: 'string[]', required: true, constraint: 'The updated, deduped project list in manifest order.' },
28
- { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
29
- ],
30
- outputKind: 'object',
31
- effects: ["Appends to the profile manifest's `projects` array. Invalidates the process scope cache."],
32
- },
33
- run: async (input) => {
34
- const { profileId } = loadProfileManifest(input['profile']);
35
- const { manifest } = addProfileProject(profileId, input['dir']);
36
- return {
37
- profile_id: profileId,
38
- projects: manifest.projects,
39
- follow_up: `Show the full manifest with \`crtr profile show ${profileId}\`.`,
40
- };
41
- },
42
- });
@@ -1 +0,0 @@
1
- export declare const removeProjectLeaf: import("../../core/command.js").LeafDef;
@@ -1,42 +0,0 @@
1
- import { defineLeaf } from '../../core/command.js';
2
- import { loadProfileManifest, removeProfileProject } from '../../core/profiles/manifest.js';
3
- export const removeProjectLeaf = defineLeaf({
4
- name: 'remove-project',
5
- description: "remove a project directory from a profile's purview",
6
- whenToUse: "a profile no longer needs purview over one of its listed project directories — narrow it back. Works even if the directory itself was since deleted.",
7
- help: {
8
- name: 'profile remove-project',
9
- summary: "remove a project directory from a profile's manifest",
10
- params: [
11
- {
12
- kind: 'positional',
13
- name: 'profile',
14
- required: true,
15
- constraint: 'Exact profile id, or a unique manifest name.',
16
- },
17
- {
18
- kind: 'flag',
19
- name: 'dir',
20
- type: 'path',
21
- required: true,
22
- constraint: 'Directory to remove, matched by its resolved real path against the manifest. Does not require the directory to still exist on disk.',
23
- },
24
- ],
25
- output: [
26
- { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
27
- { name: 'projects', type: 'string[]', required: true, constraint: 'The updated project list in manifest order.' },
28
- { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
29
- ],
30
- outputKind: 'object',
31
- effects: ["Removes an entry from the profile manifest's `projects` array. Invalidates the process scope cache."],
32
- },
33
- run: async (input) => {
34
- const { profileId } = loadProfileManifest(input['profile']);
35
- const { manifest } = removeProfileProject(profileId, input['dir']);
36
- return {
37
- profile_id: profileId,
38
- projects: manifest.projects,
39
- follow_up: `Show the full manifest with \`crtr profile show ${profileId}\`.`,
40
- };
41
- },
42
- });