@automagik/genie 4.260331.5 → 4.260331.7
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/.claude-plugin/marketplace.json +1 -1
- package/CLAUDE.md +31 -5
- package/dist/genie.js +600 -691
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/skills/genie/SKILL.md +71 -233
- package/skills/genie/reference/lifecycle.md +65 -0
- package/src/db/migrations/015_agent_archived_state.sql +0 -20
- package/src/db/migrations/018_drop_app_store.sql +4 -0
- package/src/genie.ts +26 -14
- package/src/lib/agent-sync.ts +55 -194
- package/src/lib/export-format.ts +1 -12
- package/src/lib/import-order.ts +1 -11
- package/src/term-commands/agent/directory.ts +2 -37
- package/src/term-commands/agent/index.ts +6 -0
- package/src/term-commands/dir.ts +3 -160
- package/src/term-commands/export.ts +0 -13
- package/src/term-commands/msg.ts +8 -0
- package/src/term-commands/team.ts +8 -0
- package/src/lib/agent-cache.ts +0 -282
- package/src/lib/manifest.ts +0 -342
- package/src/term-commands/install.ts +0 -372
- package/src/term-commands/item-uninstall.ts +0 -118
- package/src/term-commands/item-update.ts +0 -205
- package/src/term-commands/publish.ts +0 -187
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* genie export projects — Projects
|
|
10
10
|
* genie export schedules [name] — Schedules with run_spec
|
|
11
11
|
* genie export agents — Agents, templates, checkpoints
|
|
12
|
-
* genie export apps — App store (KhalOS, graceful skip)
|
|
13
12
|
* genie export comms — Conversations, messages, mailbox
|
|
14
13
|
* genie export config — OS config (KhalOS, graceful skip)
|
|
15
14
|
*/
|
|
@@ -374,18 +373,6 @@ export function registerExportCommands(program: Command): void {
|
|
|
374
373
|
},
|
|
375
374
|
);
|
|
376
375
|
|
|
377
|
-
// genie export apps
|
|
378
|
-
sharedOpts(exp.command('apps').description('Export app store (graceful skip if missing)')).action(
|
|
379
|
-
async (options: ExportOptions) => {
|
|
380
|
-
try {
|
|
381
|
-
await runExport(['apps'], 'partial', (sql) => exportGroup(sql, 'apps'), options);
|
|
382
|
-
} catch (error) {
|
|
383
|
-
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
384
|
-
process.exit(1);
|
|
385
|
-
}
|
|
386
|
-
},
|
|
387
|
-
);
|
|
388
|
-
|
|
389
376
|
// genie export comms
|
|
390
377
|
sharedOpts(exp.command('comms').description('Export conversations, messages, mailbox')).action(
|
|
391
378
|
async (options: ExportOptions) => {
|
package/src/term-commands/msg.ts
CHANGED
|
@@ -526,6 +526,14 @@ export function registerSendInboxCommands(program: Command): void {
|
|
|
526
526
|
.option('--to <agent>', 'Recipient agent name (default: team-lead)', 'team-lead')
|
|
527
527
|
.option('--from <sender>', 'Sender ID (auto-detected from context)')
|
|
528
528
|
.option('--team <name>', 'Explicit team context for sender/recipient resolution')
|
|
529
|
+
.addHelpText(
|
|
530
|
+
'after',
|
|
531
|
+
`
|
|
532
|
+
Examples:
|
|
533
|
+
genie send 'start task #3' --to engineer # Message a specific agent
|
|
534
|
+
genie send 'status update' --to team-lead # Report to team lead
|
|
535
|
+
genie send 'deploy ready' --team my-feature # Message within team context`,
|
|
536
|
+
)
|
|
529
537
|
.action(async (body: string, options: { to: string; from?: string; team?: string }) => {
|
|
530
538
|
try {
|
|
531
539
|
await handleSend(body, options);
|
|
@@ -29,6 +29,14 @@ export function registerTeamNamespace(program: Command): void {
|
|
|
29
29
|
.option('--tmux-session <name>', 'Tmux session to place team window in (default: derived from repo path)')
|
|
30
30
|
.option('--session <name>', 'Alias for --tmux-session (deprecated)')
|
|
31
31
|
.option('--no-spawn', 'Create team and copy wish without spawning the leader (useful for testing)')
|
|
32
|
+
.addHelpText(
|
|
33
|
+
'after',
|
|
34
|
+
`
|
|
35
|
+
Examples:
|
|
36
|
+
genie team create my-feature --repo . # Create team in current repo
|
|
37
|
+
genie team create my-feature --repo . --wish my-feature-slug # Create team with a wish
|
|
38
|
+
genie team create hotfix --repo . --branch main # Create from main branch`,
|
|
39
|
+
)
|
|
32
40
|
.action(
|
|
33
41
|
async (
|
|
34
42
|
name: string,
|
package/src/lib/agent-cache.ts
DELETED
|
@@ -1,282 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent Cache — Maintains agent-directory.json as a cache of app_store agents.
|
|
3
|
-
*
|
|
4
|
-
* Also provides generic CRUD helpers for the `app_store` table (register,
|
|
5
|
-
* remove, update, get, list). The cache file is a denormalised snapshot
|
|
6
|
-
* written to GENIE_HOME so that non-PG consumers (hooks, shell scripts)
|
|
7
|
-
* can read agent metadata without a database connection.
|
|
8
|
-
*
|
|
9
|
-
* Best-effort: DB failures never block the CLI.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
13
|
-
import { homedir } from 'node:os';
|
|
14
|
-
import { join } from 'node:path';
|
|
15
|
-
import { getActor, recordAuditEvent } from './audit.js';
|
|
16
|
-
import { getConnection, isAvailable } from './db.js';
|
|
17
|
-
|
|
18
|
-
// ============================================================================
|
|
19
|
-
// Constants
|
|
20
|
-
// ============================================================================
|
|
21
|
-
|
|
22
|
-
const GENIE_HOME = process.env.GENIE_HOME ?? join(homedir(), '.genie');
|
|
23
|
-
const CACHE_FILE = 'agent-directory.json';
|
|
24
|
-
const CACHE_BACKUP = 'agent-directory.json.bak';
|
|
25
|
-
|
|
26
|
-
// ============================================================================
|
|
27
|
-
// Types
|
|
28
|
-
// ============================================================================
|
|
29
|
-
|
|
30
|
-
/** Denormalised cache entry written to agent-directory.json. */
|
|
31
|
-
interface CacheEntry {
|
|
32
|
-
name: string;
|
|
33
|
-
dir: string;
|
|
34
|
-
repo?: string;
|
|
35
|
-
promptMode: string;
|
|
36
|
-
model?: string;
|
|
37
|
-
roles?: string[];
|
|
38
|
-
registeredAt: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Row shape returned by SELECT * FROM app_store. */
|
|
42
|
-
export interface StoreRow {
|
|
43
|
-
id: string;
|
|
44
|
-
name: string;
|
|
45
|
-
item_type: string;
|
|
46
|
-
version: string;
|
|
47
|
-
description: string | null;
|
|
48
|
-
author_name: string | null;
|
|
49
|
-
author_url: string | null;
|
|
50
|
-
git_url: string | null;
|
|
51
|
-
install_path: string | null;
|
|
52
|
-
manifest: Record<string, unknown>;
|
|
53
|
-
approval_status: string;
|
|
54
|
-
tags: string[];
|
|
55
|
-
category: string | null;
|
|
56
|
-
license: string | null;
|
|
57
|
-
dependencies: string[];
|
|
58
|
-
installed_at: string;
|
|
59
|
-
updated_at: string;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Insert payload for registerItemInStore. */
|
|
63
|
-
interface StoreInsert {
|
|
64
|
-
name: string;
|
|
65
|
-
itemType: string;
|
|
66
|
-
version?: string;
|
|
67
|
-
description?: string;
|
|
68
|
-
authorName?: string;
|
|
69
|
-
authorUrl?: string;
|
|
70
|
-
gitUrl?: string;
|
|
71
|
-
installPath?: string;
|
|
72
|
-
manifest?: Record<string, unknown>;
|
|
73
|
-
tags?: string[];
|
|
74
|
-
category?: string;
|
|
75
|
-
license?: string;
|
|
76
|
-
dependencies?: string[];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ============================================================================
|
|
80
|
-
// Cache regeneration
|
|
81
|
-
// ============================================================================
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Rebuild agent-directory.json from all `item_type = 'agent'` rows in app_store.
|
|
85
|
-
*
|
|
86
|
-
* Silently returns if the database is unavailable — the stale cache (if any)
|
|
87
|
-
* remains on disk until the next successful regeneration.
|
|
88
|
-
*/
|
|
89
|
-
export async function regenerateAgentCache(): Promise<void> {
|
|
90
|
-
try {
|
|
91
|
-
if (!(await isAvailable())) return;
|
|
92
|
-
|
|
93
|
-
const sql = await getConnection();
|
|
94
|
-
const rows = await sql`
|
|
95
|
-
SELECT name, install_path, manifest, installed_at
|
|
96
|
-
FROM app_store
|
|
97
|
-
WHERE item_type = 'agent'
|
|
98
|
-
ORDER BY name
|
|
99
|
-
`;
|
|
100
|
-
|
|
101
|
-
const entries: CacheEntry[] = rows.map((r: Record<string, unknown>) => {
|
|
102
|
-
const manifest = (r.manifest ?? {}) as Record<string, unknown>;
|
|
103
|
-
const entry: CacheEntry = {
|
|
104
|
-
name: r.name as string,
|
|
105
|
-
dir: (r.install_path as string) ?? '',
|
|
106
|
-
promptMode: (manifest.promptMode as string) ?? 'append',
|
|
107
|
-
registeredAt: r.installed_at ? new Date(r.installed_at as string).toISOString() : new Date().toISOString(),
|
|
108
|
-
};
|
|
109
|
-
if (manifest.repo) entry.repo = manifest.repo as string;
|
|
110
|
-
if (manifest.model) entry.model = manifest.model as string;
|
|
111
|
-
if (Array.isArray(manifest.roles) && manifest.roles.length > 0) {
|
|
112
|
-
entry.roles = manifest.roles as string[];
|
|
113
|
-
}
|
|
114
|
-
return entry;
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
writeFileSync(join(GENIE_HOME, CACHE_FILE), JSON.stringify(entries, null, 2));
|
|
118
|
-
} catch {
|
|
119
|
-
// Best effort — never block the CLI on cache regeneration failure
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ============================================================================
|
|
124
|
-
// One-time migration from JSON → PG
|
|
125
|
-
// ============================================================================
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Migrate the legacy agent-directory.json into the app_store table.
|
|
129
|
-
*
|
|
130
|
-
* Idempotent: skips if the backup file already exists (meaning migration
|
|
131
|
-
* already ran) or if the source file is missing (nothing to migrate).
|
|
132
|
-
* Uses ON CONFLICT (name) DO NOTHING so partially-completed runs are safe.
|
|
133
|
-
*/
|
|
134
|
-
export async function migrateAgentDirectory(): Promise<void> {
|
|
135
|
-
const sourcePath = join(GENIE_HOME, CACHE_FILE);
|
|
136
|
-
const backupPath = join(GENIE_HOME, CACHE_BACKUP);
|
|
137
|
-
|
|
138
|
-
// Already migrated or nothing to migrate
|
|
139
|
-
if (existsSync(backupPath) || !existsSync(sourcePath)) return;
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
const raw = readFileSync(sourcePath, 'utf-8');
|
|
143
|
-
const entries: CacheEntry[] = JSON.parse(raw);
|
|
144
|
-
if (!Array.isArray(entries) || entries.length === 0) return;
|
|
145
|
-
|
|
146
|
-
const sql = await getConnection();
|
|
147
|
-
|
|
148
|
-
for (const entry of entries) {
|
|
149
|
-
const manifest: Record<string, unknown> = {};
|
|
150
|
-
if (entry.promptMode) manifest.promptMode = entry.promptMode;
|
|
151
|
-
if (entry.model) manifest.model = entry.model;
|
|
152
|
-
if (entry.roles) manifest.roles = entry.roles;
|
|
153
|
-
if (entry.repo) manifest.repo = entry.repo;
|
|
154
|
-
|
|
155
|
-
await sql`
|
|
156
|
-
INSERT INTO app_store (name, item_type, version, install_path, manifest)
|
|
157
|
-
VALUES (${entry.name}, 'agent', '0.0.0', ${entry.dir ?? null}, ${sql.json(manifest)})
|
|
158
|
-
ON CONFLICT (name) DO NOTHING
|
|
159
|
-
`;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
renameSync(sourcePath, backupPath);
|
|
163
|
-
|
|
164
|
-
await recordAuditEvent('item', 'migration', 'agent_directory_migrated', getActor(), {
|
|
165
|
-
count: entries.length,
|
|
166
|
-
});
|
|
167
|
-
} catch {
|
|
168
|
-
// Best effort — migration can be retried on the next run
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ============================================================================
|
|
173
|
-
// CRUD helpers for app_store
|
|
174
|
-
// ============================================================================
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Insert a new item into the app_store. Returns the generated id.
|
|
178
|
-
*
|
|
179
|
-
* Throws if an item with the same name already exists — callers should
|
|
180
|
-
* check beforehand or use `--force` to remove + re-insert.
|
|
181
|
-
*/
|
|
182
|
-
export async function registerItemInStore(item: StoreInsert): Promise<string> {
|
|
183
|
-
const sql = await getConnection();
|
|
184
|
-
|
|
185
|
-
const rows = await sql`
|
|
186
|
-
INSERT INTO app_store (
|
|
187
|
-
name, item_type, version, description,
|
|
188
|
-
author_name, author_url, git_url, install_path,
|
|
189
|
-
manifest, tags, category, license, dependencies
|
|
190
|
-
) VALUES (
|
|
191
|
-
${item.name},
|
|
192
|
-
${item.itemType},
|
|
193
|
-
${item.version ?? '0.0.0'},
|
|
194
|
-
${item.description ?? null},
|
|
195
|
-
${item.authorName ?? null},
|
|
196
|
-
${item.authorUrl ?? null},
|
|
197
|
-
${item.gitUrl ?? null},
|
|
198
|
-
${item.installPath ?? null},
|
|
199
|
-
${sql.json(item.manifest ?? {})},
|
|
200
|
-
${item.tags ?? []},
|
|
201
|
-
${item.category ?? null},
|
|
202
|
-
${item.license ?? null},
|
|
203
|
-
${item.dependencies ?? []}
|
|
204
|
-
)
|
|
205
|
-
RETURNING id
|
|
206
|
-
`;
|
|
207
|
-
|
|
208
|
-
if (rows.length === 0) {
|
|
209
|
-
throw new Error(`Failed to insert item "${item.name}" — no id returned.`);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return rows[0].id as string;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Delete an item from the app_store by name.
|
|
217
|
-
*
|
|
218
|
-
* @returns true if an item was deleted, false if no matching item was found.
|
|
219
|
-
*/
|
|
220
|
-
export async function removeItemFromStore(name: string): Promise<boolean> {
|
|
221
|
-
const sql = await getConnection();
|
|
222
|
-
const result = await sql`DELETE FROM app_store WHERE name = ${name}`;
|
|
223
|
-
return result.count > 0;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Update an existing item in the app_store by name.
|
|
228
|
-
* Only provided fields are updated; updated_at is always set to now().
|
|
229
|
-
*
|
|
230
|
-
* @returns true if the item was updated, false if no matching item was found.
|
|
231
|
-
*/
|
|
232
|
-
export async function updateItemInStore(name: string, updates: Partial<StoreInsert>): Promise<boolean> {
|
|
233
|
-
const sql = await getConnection();
|
|
234
|
-
|
|
235
|
-
const s: Record<string, unknown> = {};
|
|
236
|
-
if (updates.name !== undefined) s.name = updates.name;
|
|
237
|
-
if (updates.itemType !== undefined) s.item_type = updates.itemType;
|
|
238
|
-
if (updates.version !== undefined) s.version = updates.version;
|
|
239
|
-
if (updates.description !== undefined) s.description = updates.description;
|
|
240
|
-
if (updates.authorName !== undefined) s.author_name = updates.authorName;
|
|
241
|
-
if (updates.authorUrl !== undefined) s.author_url = updates.authorUrl;
|
|
242
|
-
if (updates.gitUrl !== undefined) s.git_url = updates.gitUrl;
|
|
243
|
-
if (updates.installPath !== undefined) s.install_path = updates.installPath;
|
|
244
|
-
if (updates.manifest !== undefined) s.manifest = sql.json(updates.manifest);
|
|
245
|
-
if (updates.tags !== undefined) s.tags = updates.tags;
|
|
246
|
-
if (updates.category !== undefined) s.category = updates.category;
|
|
247
|
-
if (updates.license !== undefined) s.license = updates.license;
|
|
248
|
-
if (updates.dependencies !== undefined) s.dependencies = updates.dependencies;
|
|
249
|
-
|
|
250
|
-
if (Object.keys(s).length === 0) return false;
|
|
251
|
-
|
|
252
|
-
s.updated_at = sql`now()`;
|
|
253
|
-
const result = await sql`UPDATE app_store SET ${sql(s)} WHERE name = ${name}`;
|
|
254
|
-
return result.count > 0;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Fetch a single item from the app_store by name.
|
|
259
|
-
*
|
|
260
|
-
* @returns the row or null if not found.
|
|
261
|
-
*/
|
|
262
|
-
export async function getItemFromStore(name: string): Promise<StoreRow | null> {
|
|
263
|
-
const sql = await getConnection();
|
|
264
|
-
const rows = await sql`SELECT * FROM app_store WHERE name = ${name}`;
|
|
265
|
-
return rows.length > 0 ? (rows[0] as unknown as StoreRow) : null;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* List items from the app_store, optionally filtered by item_type.
|
|
270
|
-
*
|
|
271
|
-
* @param itemType - When provided, only rows matching this type are returned.
|
|
272
|
-
* @returns rows ordered by name.
|
|
273
|
-
*/
|
|
274
|
-
export async function listItemsFromStore(itemType?: string): Promise<StoreRow[]> {
|
|
275
|
-
const sql = await getConnection();
|
|
276
|
-
|
|
277
|
-
const rows = itemType
|
|
278
|
-
? await sql`SELECT * FROM app_store WHERE item_type = ${itemType} ORDER BY name`
|
|
279
|
-
: await sql`SELECT * FROM app_store ORDER BY name`;
|
|
280
|
-
|
|
281
|
-
return rows as unknown as StoreRow[];
|
|
282
|
-
}
|
package/src/lib/manifest.ts
DELETED
|
@@ -1,342 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Manifest — Genie item manifest parsing, validation, and detection.
|
|
3
|
-
*
|
|
4
|
-
* Every registry item (agent, skill, app, board, workflow, stack, template, hook)
|
|
5
|
-
* is described by a `genie.yaml` manifest. This module handles:
|
|
6
|
-
* - Parsing YAML manifests into typed GenieManifest objects
|
|
7
|
-
* - Validating manifests against type-specific rules
|
|
8
|
-
* - Auto-detecting manifests from directory conventions (AGENTS.md, skill.md, etc.)
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
-
import { basename, join } from 'node:path';
|
|
13
|
-
import * as yaml from 'js-yaml';
|
|
14
|
-
|
|
15
|
-
// ============================================================================
|
|
16
|
-
// Types
|
|
17
|
-
// ============================================================================
|
|
18
|
-
|
|
19
|
-
export type ItemType = 'agent' | 'skill' | 'app' | 'board' | 'workflow' | 'stack' | 'template' | 'hook';
|
|
20
|
-
|
|
21
|
-
const ITEM_TYPES: ItemType[] = ['agent', 'skill', 'app', 'board', 'workflow', 'stack', 'template', 'hook'];
|
|
22
|
-
|
|
23
|
-
export interface StageConfig {
|
|
24
|
-
name: string;
|
|
25
|
-
label?: string;
|
|
26
|
-
gate: 'human' | 'agent' | 'human+agent';
|
|
27
|
-
action?: string;
|
|
28
|
-
auto_advance?: boolean;
|
|
29
|
-
roles?: string[];
|
|
30
|
-
color?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface StackItem {
|
|
34
|
-
name: string;
|
|
35
|
-
type: ItemType;
|
|
36
|
-
source?: string;
|
|
37
|
-
inline?: boolean;
|
|
38
|
-
config?: Record<string, unknown>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface GenieManifest {
|
|
42
|
-
name: string;
|
|
43
|
-
version: string;
|
|
44
|
-
type: ItemType;
|
|
45
|
-
description?: string;
|
|
46
|
-
author?: { name: string; url?: string };
|
|
47
|
-
agent?: { model?: string; promptMode?: string; roles?: string[]; entrypoint: string };
|
|
48
|
-
skill?: { triggers?: string[]; entrypoint: string };
|
|
49
|
-
app?: { runtime?: string; natsPrefix?: string; icon?: string; entrypoint: string };
|
|
50
|
-
board?: { stages: StageConfig[] };
|
|
51
|
-
workflow?: { cron: string; timezone?: string; command: string; run_spec?: Record<string, unknown> };
|
|
52
|
-
stack?: { items: StackItem[] };
|
|
53
|
-
dependencies?: string[];
|
|
54
|
-
tags?: string[];
|
|
55
|
-
category?: string;
|
|
56
|
-
license?: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface ValidationResult {
|
|
60
|
-
valid: boolean;
|
|
61
|
-
errors: string[];
|
|
62
|
-
warnings: string[];
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ============================================================================
|
|
66
|
-
// Parsing
|
|
67
|
-
// ============================================================================
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Parse a YAML string into a typed GenieManifest.
|
|
71
|
-
*
|
|
72
|
-
* Validates that required fields (name, type, version) are present and that
|
|
73
|
-
* the type value is a recognized ItemType. Throws on invalid input.
|
|
74
|
-
*/
|
|
75
|
-
function parseManifest(yamlContent: string): GenieManifest {
|
|
76
|
-
const raw = yaml.load(yamlContent);
|
|
77
|
-
if (!raw || typeof raw !== 'object') {
|
|
78
|
-
throw new Error('Manifest YAML is empty or not an object');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const obj = raw as Record<string, unknown>;
|
|
82
|
-
|
|
83
|
-
if (!obj.name || typeof obj.name !== 'string') {
|
|
84
|
-
throw new Error('Manifest is missing required field: name');
|
|
85
|
-
}
|
|
86
|
-
if (!obj.type || typeof obj.type !== 'string') {
|
|
87
|
-
throw new Error('Manifest is missing required field: type');
|
|
88
|
-
}
|
|
89
|
-
if (!obj.version || typeof obj.version !== 'string') {
|
|
90
|
-
throw new Error('Manifest is missing required field: version');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!ITEM_TYPES.includes(obj.type as ItemType)) {
|
|
94
|
-
throw new Error(`Invalid item type "${obj.type}". Must be one of: ${ITEM_TYPES.join(', ')}`);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return obj as unknown as GenieManifest;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ============================================================================
|
|
101
|
-
// Validation
|
|
102
|
-
// ============================================================================
|
|
103
|
-
|
|
104
|
-
const VALID_GATES = new Set(['human', 'agent', 'human+agent']);
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Validate a manifest against type-specific rules.
|
|
108
|
-
*
|
|
109
|
-
* Checks required fields, type-specific sections, entrypoint file existence,
|
|
110
|
-
* board stage structure, cron format, and stack item completeness.
|
|
111
|
-
* Returns a ValidationResult with errors (blockers) and warnings (advisory).
|
|
112
|
-
*/
|
|
113
|
-
export function validateManifest(manifest: GenieManifest, itemDir: string): ValidationResult {
|
|
114
|
-
const errors: string[] = [];
|
|
115
|
-
const warnings: string[] = [];
|
|
116
|
-
|
|
117
|
-
// Required top-level fields
|
|
118
|
-
if (!manifest.name) errors.push('Missing required field: name');
|
|
119
|
-
if (!manifest.type) errors.push('Missing required field: type');
|
|
120
|
-
if (!manifest.version) errors.push('Missing required field: version');
|
|
121
|
-
|
|
122
|
-
if (manifest.type && !ITEM_TYPES.includes(manifest.type)) {
|
|
123
|
-
errors.push(`Invalid item type "${manifest.type}". Must be one of: ${ITEM_TYPES.join(', ')}`);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Type-specific validation
|
|
127
|
-
const { type } = manifest;
|
|
128
|
-
|
|
129
|
-
// Warn if the type-specific section is missing
|
|
130
|
-
if (type && !manifest[type as keyof GenieManifest]) {
|
|
131
|
-
warnings.push(`Manifest type is "${type}" but no "${type}" section is defined`);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Agent validation
|
|
135
|
-
if (manifest.agent) {
|
|
136
|
-
validateEntrypoint(manifest.agent.entrypoint, itemDir, 'agent', errors);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Skill validation
|
|
140
|
-
if (manifest.skill) {
|
|
141
|
-
validateEntrypoint(manifest.skill.entrypoint, itemDir, 'skill', errors);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// App validation
|
|
145
|
-
if (manifest.app) {
|
|
146
|
-
validateEntrypoint(manifest.app.entrypoint, itemDir, 'app', errors);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Board validation
|
|
150
|
-
if (manifest.board) {
|
|
151
|
-
validateStages(manifest.board.stages, errors);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Workflow validation
|
|
155
|
-
if (manifest.workflow) {
|
|
156
|
-
validateCron(manifest.workflow.cron, errors);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Stack validation
|
|
160
|
-
if (manifest.stack) {
|
|
161
|
-
validateStackItems(manifest.stack.items, errors);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Check that an entrypoint file exists on disk. */
|
|
168
|
-
function validateEntrypoint(entrypoint: string | undefined, itemDir: string, section: string, errors: string[]): void {
|
|
169
|
-
if (!entrypoint) {
|
|
170
|
-
errors.push(`${section} section is missing required field: entrypoint`);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
const fullPath = join(itemDir, entrypoint);
|
|
174
|
-
if (!existsSync(fullPath)) {
|
|
175
|
-
errors.push(`${section} entrypoint not found: ${entrypoint} (expected at ${fullPath})`);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Validate board stage definitions. */
|
|
180
|
-
function validateStages(stages: StageConfig[] | undefined, errors: string[]): void {
|
|
181
|
-
if (!stages || !Array.isArray(stages)) {
|
|
182
|
-
errors.push('board section is missing required field: stages');
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
for (let i = 0; i < stages.length; i++) {
|
|
186
|
-
const stage = stages[i];
|
|
187
|
-
if (!stage.name) {
|
|
188
|
-
errors.push(`board.stages[${i}] is missing required field: name`);
|
|
189
|
-
}
|
|
190
|
-
if (!stage.gate) {
|
|
191
|
-
errors.push(`board.stages[${i}] is missing required field: gate`);
|
|
192
|
-
} else if (!VALID_GATES.has(stage.gate)) {
|
|
193
|
-
errors.push(`board.stages[${i}].gate "${stage.gate}" is invalid. Must be one of: human, agent, human+agent`);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Validate a cron expression has 5 or 6 space-separated fields.
|
|
200
|
-
* This is a basic format check, not full cron syntax validation.
|
|
201
|
-
*/
|
|
202
|
-
function validateCron(cron: string | undefined, errors: string[]): void {
|
|
203
|
-
if (!cron) {
|
|
204
|
-
errors.push('workflow section is missing required field: cron');
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
const fields = cron.trim().split(/\s+/);
|
|
208
|
-
if (fields.length < 5 || fields.length > 6) {
|
|
209
|
-
errors.push(`workflow.cron "${cron}" is invalid: expected 5 or 6 space-separated fields, got ${fields.length}`);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Validate stack item definitions. */
|
|
214
|
-
function validateStackItems(items: StackItem[] | undefined, errors: string[]): void {
|
|
215
|
-
if (!items || !Array.isArray(items)) {
|
|
216
|
-
errors.push('stack section is missing required field: items');
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
for (let i = 0; i < items.length; i++) {
|
|
220
|
-
const item = items[i];
|
|
221
|
-
if (!item.type) {
|
|
222
|
-
errors.push(`stack.items[${i}] is missing required field: type`);
|
|
223
|
-
}
|
|
224
|
-
if (!item.source && !item.inline) {
|
|
225
|
-
errors.push(`stack.items[${i}] must have either "source" or "inline: true"`);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// ============================================================================
|
|
231
|
-
// Detection
|
|
232
|
-
// ============================================================================
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Detect a manifest from a directory using a fallback chain.
|
|
236
|
-
*
|
|
237
|
-
* Detection order:
|
|
238
|
-
* 1. `genie.yaml` — explicit manifest file
|
|
239
|
-
* 2. `AGENTS.md` — infer agent manifest from YAML frontmatter
|
|
240
|
-
* 3. `manifest.ts` — infer app manifest
|
|
241
|
-
* 4. `skill.md` — infer skill manifest
|
|
242
|
-
* 5. None found — return error
|
|
243
|
-
*/
|
|
244
|
-
export async function detectManifest(
|
|
245
|
-
dir: string,
|
|
246
|
-
): Promise<{ manifest: GenieManifest; source: string } | { error: string }> {
|
|
247
|
-
// 1. Explicit genie.yaml
|
|
248
|
-
const yamlPath = join(dir, 'genie.yaml');
|
|
249
|
-
if (existsSync(yamlPath)) {
|
|
250
|
-
try {
|
|
251
|
-
const content = readFileSync(yamlPath, 'utf-8');
|
|
252
|
-
const manifest = parseManifest(content);
|
|
253
|
-
return { manifest, source: 'genie.yaml' };
|
|
254
|
-
} catch (err) {
|
|
255
|
-
return { error: `Failed to parse genie.yaml: ${(err as Error).message}` };
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const dirName = basename(dir);
|
|
260
|
-
|
|
261
|
-
// 2. AGENTS.md — infer agent manifest from frontmatter
|
|
262
|
-
const agentsPath = join(dir, 'AGENTS.md');
|
|
263
|
-
if (existsSync(agentsPath)) {
|
|
264
|
-
const manifest = inferAgentManifest(agentsPath, dirName);
|
|
265
|
-
return { manifest, source: 'AGENTS.md' };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// 3. manifest.ts — infer app manifest
|
|
269
|
-
const manifestTsPath = join(dir, 'manifest.ts');
|
|
270
|
-
if (existsSync(manifestTsPath)) {
|
|
271
|
-
const manifest: GenieManifest = {
|
|
272
|
-
name: dirName,
|
|
273
|
-
type: 'app',
|
|
274
|
-
version: '0.0.0',
|
|
275
|
-
};
|
|
276
|
-
return { manifest, source: 'manifest.ts' };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// 4. skill.md — infer skill manifest
|
|
280
|
-
const skillPath = join(dir, 'skill.md');
|
|
281
|
-
if (existsSync(skillPath)) {
|
|
282
|
-
const manifest: GenieManifest = {
|
|
283
|
-
name: dirName,
|
|
284
|
-
type: 'skill',
|
|
285
|
-
version: '0.0.0',
|
|
286
|
-
};
|
|
287
|
-
return { manifest, source: 'skill.md' };
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// 5. Nothing found
|
|
291
|
-
return {
|
|
292
|
-
error:
|
|
293
|
-
'No manifest found. Create a genie.yaml or use a recognized file pattern (AGENTS.md, manifest.ts, skill.md).',
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Extract YAML frontmatter from a markdown file.
|
|
299
|
-
*
|
|
300
|
-
* Looks for content between `---` markers at the top of the file.
|
|
301
|
-
* Returns the parsed object or null if no frontmatter is found.
|
|
302
|
-
*/
|
|
303
|
-
function extractFrontmatter(filePath: string): Record<string, unknown> | null {
|
|
304
|
-
const content = readFileSync(filePath, 'utf-8');
|
|
305
|
-
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
306
|
-
if (!match) return null;
|
|
307
|
-
|
|
308
|
-
try {
|
|
309
|
-
const parsed = yaml.load(match[1]);
|
|
310
|
-
if (parsed && typeof parsed === 'object') {
|
|
311
|
-
return parsed as Record<string, unknown>;
|
|
312
|
-
}
|
|
313
|
-
} catch {
|
|
314
|
-
// Frontmatter is not valid YAML — ignore
|
|
315
|
-
}
|
|
316
|
-
return null;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** Build an agent manifest from AGENTS.md frontmatter. */
|
|
320
|
-
function inferAgentManifest(agentsPath: string, dirName: string): GenieManifest {
|
|
321
|
-
const frontmatter = extractFrontmatter(agentsPath);
|
|
322
|
-
|
|
323
|
-
const name = (frontmatter?.name as string) || dirName;
|
|
324
|
-
const model = frontmatter?.model as string | undefined;
|
|
325
|
-
const roles = Array.isArray(frontmatter?.roles) ? (frontmatter.roles as string[]) : undefined;
|
|
326
|
-
|
|
327
|
-
const manifest: GenieManifest = {
|
|
328
|
-
name,
|
|
329
|
-
type: 'agent',
|
|
330
|
-
version: '0.0.0',
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
if (model || roles) {
|
|
334
|
-
manifest.agent = {
|
|
335
|
-
entrypoint: 'AGENTS.md',
|
|
336
|
-
...(model && { model }),
|
|
337
|
-
...(roles && { roles }),
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return manifest;
|
|
342
|
-
}
|