@phyzess/pappra 0.1.0-beta.1 → 0.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ import type { CliOutput } from '../output.js';
2
+ export declare const runtimeUsage: string;
3
+ export interface RuntimeCommandOptions {
4
+ readonly cwd: string;
5
+ readonly action: 'install' | 'status' | 'verify' | 'remove' | null;
6
+ readonly json: boolean;
7
+ readonly from: 'self' | 'tarball' | 'registry' | null;
8
+ readonly tarball: string | null;
9
+ readonly version: string | null;
10
+ readonly apply: boolean;
11
+ readonly bind: boolean;
12
+ readonly force: boolean;
13
+ }
14
+ export declare function runRuntimeCommand(options: RuntimeCommandOptions, output: CliOutput): Promise<number>;
@@ -0,0 +1,263 @@
1
+ import { resolve } from 'node:path';
2
+ import { getPackageInfo, getPackageRoot } from '../../package-info.js';
3
+ import { inspectRuntimeState, inspectRuntimeStatePassive, installRuntime, removeRuntime, resolveUserDataDir, runtimePaths, } from '../../install/runtime.js';
4
+ import { updateMachineRuntime } from '../../install/apply.js';
5
+ import { inspectMachineConfig } from '../../workspace/config.js';
6
+ import { resolveWorkspaceRoot } from '../../workspace/resolve.js';
7
+ export const runtimeUsage = 'pappra runtime <install|status|verify|remove> [options] [--json]\n' +
8
+ ' install [--from self|tarball|registry] [--tarball <file>] [--runtime-version <v>]\n' +
9
+ ' [--apply] [--bind]\n' +
10
+ ' status\n' +
11
+ ' verify [--runtime-version <v>]\n' +
12
+ ' remove [--runtime-version <v>] --apply [--force]\n\n' +
13
+ 'Install keeps a durable copy of the package outside the checkout. --from self packs the\n' +
14
+ 'package that is currently executing, which is what makes an npx/dlx bootstrap survive\n' +
15
+ 'cache eviction. Writes require --apply; --bind records the copy in machine.json.\n';
16
+ async function inspectBinding(cwd) {
17
+ const resolution = await resolveWorkspaceRoot({ startPath: cwd });
18
+ if (resolution.root === null)
19
+ return null;
20
+ const machine = await inspectMachineConfig(resolution.root);
21
+ return {
22
+ root: resolution.root,
23
+ machinePath: machine.path,
24
+ runtime: machine.value?.runtime === null || machine.value?.runtime === undefined
25
+ ? null
26
+ : {
27
+ version: machine.value.runtime.version,
28
+ executable: machine.value.runtime.executable,
29
+ },
30
+ };
31
+ }
32
+ function printDiagnostics(output, diagnostics) {
33
+ for (const diagnostic of diagnostics) {
34
+ output.stderr(` ${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`);
35
+ }
36
+ }
37
+ function sourceFor(options) {
38
+ const from = options.from ?? (options.tarball === null ? 'self' : 'tarball');
39
+ if (from === 'self')
40
+ return { kind: 'self' };
41
+ if (from === 'tarball') {
42
+ if (options.tarball === null)
43
+ return null;
44
+ return { kind: 'tarball', path: resolve(options.cwd, options.tarball) };
45
+ }
46
+ return { kind: 'version' };
47
+ }
48
+ async function runInstall(options, output, binding) {
49
+ const packageInfo = getPackageInfo();
50
+ const version = options.version ?? packageInfo.version;
51
+ const source = sourceFor(options);
52
+ if (source === null) {
53
+ output.stderr('pappra runtime install: --from tarball requires --tarball.\n');
54
+ return 2;
55
+ }
56
+ const dataDir = resolveUserDataDir();
57
+ const paths = runtimePaths(version, dataDir);
58
+ if (!options.apply) {
59
+ if (options.json) {
60
+ output.stdout(`${JSON.stringify({
61
+ ok: true,
62
+ command: 'runtime',
63
+ action: 'install',
64
+ mode: 'plan',
65
+ version,
66
+ source: source.kind,
67
+ dataDir,
68
+ runtimeRoot: paths.runtimeRoot,
69
+ bind: options.bind,
70
+ workspaceRoot: binding?.root ?? null,
71
+ nextActions: ['Rerun with --apply to install this exact runtime.'],
72
+ })}\n`);
73
+ }
74
+ else {
75
+ output.stdout(`Runtime plan: ${packageInfo.name}@${version}\n`);
76
+ output.stdout(`Source: ${source.kind}\n`);
77
+ output.stdout(`Target: ${paths.runtimeRoot}\n`);
78
+ output.stdout('Plan only; rerun with --apply to install it.\n');
79
+ }
80
+ return 0;
81
+ }
82
+ const installed = await installRuntime({
83
+ version,
84
+ source: source.kind === 'tarball'
85
+ ? { kind: 'tarball', path: source.path }
86
+ : source.kind === 'self'
87
+ ? { kind: 'self', path: getPackageRoot() }
88
+ : { kind: 'version' },
89
+ packageName: packageInfo.name,
90
+ });
91
+ let bindingResult = null;
92
+ if (installed.ok && installed.manifest !== null && options.bind) {
93
+ if (binding === null) {
94
+ output.stderr('pappra runtime install: --bind requires a registered workspace root.\n');
95
+ return 1;
96
+ }
97
+ const updated = await updateMachineRuntime(binding.root, installed.manifest);
98
+ bindingResult = { ok: updated.ok, diagnostics: updated.diagnostics };
99
+ if (!updated.ok) {
100
+ if (options.json) {
101
+ output.stdout(`${JSON.stringify({
102
+ ok: false,
103
+ command: 'runtime',
104
+ action: 'install',
105
+ version,
106
+ runtime: installed,
107
+ bind: { ok: false, diagnostics: updated.diagnostics },
108
+ })}\n`);
109
+ }
110
+ printDiagnostics(output, updated.diagnostics);
111
+ return 1;
112
+ }
113
+ }
114
+ if (options.json) {
115
+ output.stdout(`${JSON.stringify({
116
+ ok: installed.ok,
117
+ command: 'runtime',
118
+ action: 'install',
119
+ version,
120
+ source: source.kind,
121
+ runtime: installed,
122
+ bind: bindingResult,
123
+ diagnostics: installed.diagnostics,
124
+ })}\n`);
125
+ }
126
+ else {
127
+ output.stdout(`Runtime: ${installed.status} ${version}\n`);
128
+ if (installed.runtimeRoot !== null) {
129
+ output.stdout(`Location: ${installed.runtimeRoot}\n`);
130
+ }
131
+ if (bindingResult?.ok === true) {
132
+ output.stdout('Machine binding updated.\n');
133
+ }
134
+ printDiagnostics(output, installed.diagnostics);
135
+ }
136
+ return installed.ok ? 0 : 1;
137
+ }
138
+ async function runStatus(options, output, binding) {
139
+ const packageInfo = getPackageInfo();
140
+ const version = options.version ?? binding?.runtime?.version ?? packageInfo.version;
141
+ const dataDir = resolveUserDataDir();
142
+ const state = await inspectRuntimeStatePassive(version, dataDir);
143
+ if (options.json) {
144
+ output.stdout(`${JSON.stringify({
145
+ ok: true,
146
+ command: 'runtime',
147
+ action: 'status',
148
+ version,
149
+ dataDir,
150
+ workspaceRoot: binding?.root ?? null,
151
+ machineRuntime: binding?.runtime ?? null,
152
+ runtime: {
153
+ status: state.status,
154
+ runtimeRoot: state.runtimeRoot,
155
+ packageDigestMatch: state.packageDigestMatch,
156
+ executableExists: state.executableExists,
157
+ verifiedAt: state.manifest?.verifiedAt ?? null,
158
+ source: state.manifest?.source.kind ?? null,
159
+ },
160
+ diagnostics: state.diagnostics,
161
+ })}\n`);
162
+ }
163
+ else {
164
+ output.stdout(`Runtime ${version}: ${state.status}\n`);
165
+ output.stdout(`Location: ${state.runtimeRoot}\n`);
166
+ if (state.manifest !== null) {
167
+ output.stdout(`Verified at: ${state.manifest.verifiedAt}\n`);
168
+ }
169
+ output.stdout(`Bound in machine.json: ${binding?.runtime?.version === version ? 'yes' : 'no'}\n`);
170
+ printDiagnostics(output, state.diagnostics);
171
+ }
172
+ return 0;
173
+ }
174
+ async function runVerify(options, output, binding) {
175
+ const packageInfo = getPackageInfo();
176
+ const version = options.version ?? binding?.runtime?.version ?? packageInfo.version;
177
+ const state = await inspectRuntimeState(version, resolveUserDataDir());
178
+ const ok = state.status === 'complete';
179
+ if (options.json) {
180
+ output.stdout(`${JSON.stringify({
181
+ ok,
182
+ command: 'runtime',
183
+ action: 'verify',
184
+ version,
185
+ status: state.status,
186
+ runtimeRoot: state.runtimeRoot,
187
+ manifest: state.manifest,
188
+ diagnostics: state.diagnostics,
189
+ })}\n`);
190
+ }
191
+ else {
192
+ output.stdout(`Runtime ${version}: ${state.status}\n`);
193
+ printDiagnostics(output, state.diagnostics);
194
+ }
195
+ return ok ? 0 : 1;
196
+ }
197
+ async function runRemove(options, output, binding) {
198
+ const version = options.version ?? binding?.runtime?.version ?? null;
199
+ if (version === null) {
200
+ output.stderr('pappra runtime remove: --version is required.\n');
201
+ return 2;
202
+ }
203
+ if (!options.apply) {
204
+ output.stderr('pappra runtime remove: rerun with --apply to remove it.\n');
205
+ return 2;
206
+ }
207
+ const boundHere = binding?.runtime?.version === version;
208
+ if (boundHere && !options.force) {
209
+ const message = 'machine.json still binds this runtime; pass --force to remove it anyway.';
210
+ if (options.json) {
211
+ output.stdout(`${JSON.stringify({
212
+ ok: false,
213
+ command: 'runtime',
214
+ action: 'remove',
215
+ version,
216
+ message,
217
+ })}\n`);
218
+ }
219
+ else {
220
+ output.stderr(`pappra runtime remove: ${message}\n`);
221
+ }
222
+ return 1;
223
+ }
224
+ const removed = await removeRuntime({
225
+ version,
226
+ dataDir: resolveUserDataDir(),
227
+ // The current workspace was already checked above; callers that need to
228
+ // protect other checkouts pass --force only after checking them.
229
+ activeReferences: [],
230
+ });
231
+ if (options.json) {
232
+ output.stdout(`${JSON.stringify({
233
+ ok: removed.ok,
234
+ command: 'runtime',
235
+ action: 'remove',
236
+ version,
237
+ removed,
238
+ machineBindingRetained: boundHere,
239
+ })}\n`);
240
+ }
241
+ else {
242
+ output.stdout(`Runtime ${version}: ${removed.status}\n`);
243
+ printDiagnostics(output, removed.diagnostics);
244
+ }
245
+ return removed.ok ? 0 : 1;
246
+ }
247
+ export async function runRuntimeCommand(options, output) {
248
+ if (options.action === null) {
249
+ output.stderr(`${runtimeUsage}`);
250
+ return 2;
251
+ }
252
+ const binding = await inspectBinding(options.cwd);
253
+ switch (options.action) {
254
+ case 'install':
255
+ return runInstall(options, output, binding);
256
+ case 'status':
257
+ return runStatus(options, output, binding);
258
+ case 'verify':
259
+ return runVerify(options, output, binding);
260
+ case 'remove':
261
+ return runRemove(options, output, binding);
262
+ }
263
+ }
package/dist/cli/run.js CHANGED
@@ -7,6 +7,7 @@ import { checkpointUsage, resumeUsage, runCheckpointCommand, runResumeCommand, }
7
7
  import { codeUsage, runCodeCommand } from './commands/code.js';
8
8
  import { collaborationUsage, runCollaborationCommand, } from './commands/collaboration.js';
9
9
  import { hostUsage, runHostCommand } from './commands/host.js';
10
+ import { runtimeUsage, runRuntimeCommand } from './commands/runtime.js';
10
11
  import { hostNameSchema } from '../schemas/host.js';
11
12
  function parseOptions(args) {
12
13
  const { values, positionals } = parseArgs({
@@ -17,6 +18,15 @@ function parseOptions(args) {
17
18
  json: { type: 'boolean' },
18
19
  config: { type: 'string' },
19
20
  apply: { type: 'boolean' },
21
+ yes: { type: 'boolean', short: 'y' },
22
+ 'no-runtime': { type: 'boolean' },
23
+ from: { type: 'string' },
24
+ tarball: { type: 'string' },
25
+ out: { type: 'string' },
26
+ bind: { type: 'boolean' },
27
+ force: { type: 'boolean' },
28
+ 'no-interactive': { type: 'boolean' },
29
+ 'print-choices': { type: 'boolean' },
20
30
  'install-runtime': { type: 'boolean' },
21
31
  'runtime-tarball': { type: 'string' },
22
32
  'runtime-version': { type: 'string' },
@@ -73,6 +83,7 @@ function parseOptions(args) {
73
83
  hostAction: null,
74
84
  collaborationAction: null,
75
85
  resumeTaskId: null,
86
+ runtimeAction: null,
76
87
  };
77
88
  }
78
89
  if (command === 'resume') {
@@ -88,6 +99,7 @@ function parseOptions(args) {
88
99
  hostAction: null,
89
100
  collaborationAction: null,
90
101
  resumeTaskId: positionals[1] ?? null,
102
+ runtimeAction: null,
91
103
  };
92
104
  }
93
105
  if (command === 'code') {
@@ -103,6 +115,7 @@ function parseOptions(args) {
103
115
  hostAction: null,
104
116
  collaborationAction: null,
105
117
  resumeTaskId: null,
118
+ runtimeAction: null,
106
119
  };
107
120
  }
108
121
  if (command === 'host') {
@@ -118,6 +131,23 @@ function parseOptions(args) {
118
131
  hostAction: positionals[1] ?? null,
119
132
  collaborationAction: null,
120
133
  resumeTaskId: null,
134
+ runtimeAction: null,
135
+ };
136
+ }
137
+ if (command === 'runtime') {
138
+ if (positionals.length > 2) {
139
+ throw new Error('runtime accepts one action: install, status, verify, or remove.');
140
+ }
141
+ return {
142
+ values,
143
+ command,
144
+ docsAction: null,
145
+ docsArgument: null,
146
+ codeAction: null,
147
+ hostAction: null,
148
+ collaborationAction: null,
149
+ resumeTaskId: null,
150
+ runtimeAction: positionals[1] ?? null,
121
151
  };
122
152
  }
123
153
  if (command === 'collab') {
@@ -133,6 +163,7 @@ function parseOptions(args) {
133
163
  hostAction: null,
134
164
  collaborationAction: positionals[1] ?? null,
135
165
  resumeTaskId: null,
166
+ runtimeAction: null,
136
167
  };
137
168
  }
138
169
  if (positionals.length > 1) {
@@ -147,6 +178,7 @@ function parseOptions(args) {
147
178
  hostAction: null,
148
179
  collaborationAction: null,
149
180
  resumeTaskId: null,
181
+ runtimeAction: null,
150
182
  };
151
183
  }
152
184
  function failure(output, json, code, error) {
@@ -176,7 +208,7 @@ function hasContinuityOptions(values, includeSession = true) {
176
208
  (includeSession && values.session !== undefined));
177
209
  }
178
210
  const globalUsage = 'pappra [--help | --version] [--json]\n' +
179
- 'pappra init [--config <file>] [--apply] [--json]\n' +
211
+ 'pappra init [--config <file> | --yes] [--apply] [--json] [--print-choices]\n' +
180
212
  ' [--install-runtime (--runtime-tarball <file> | --runtime-version <version>)]\n' +
181
213
  'pappra doctor [--root <path>] [--json]\n' +
182
214
  'pappra docs <list|check|archive> [options]\n' +
@@ -184,6 +216,7 @@ const globalUsage = 'pappra [--help | --version] [--json]\n' +
184
216
  'pappra resume <taskId> [--session <id>] [--json]\n' +
185
217
  'pappra code <status|prepare|query> --repo <id> [options] [--json]\n' +
186
218
  'pappra host <detect|describe|plan|install|verify> --host <name> [options] [--json]\n' +
219
+ 'pappra runtime <install|status|verify|remove> [options] [--json]\n' +
187
220
  'pappra collab <prepare|run|query> [options]';
188
221
  export async function runCli(args, output) {
189
222
  const jsonRequested = args.includes('--json');
@@ -195,8 +228,28 @@ export async function runCli(args, output) {
195
228
  failure(output, jsonRequested, 'invalid_arguments', error);
196
229
  return 2;
197
230
  }
198
- const { values, command, docsAction, docsArgument, codeAction, hostAction, collaborationAction, resumeTaskId, } = parsed;
231
+ const { values, command, docsAction, docsArgument, codeAction, hostAction, collaborationAction, resumeTaskId, runtimeAction, } = parsed;
199
232
  const json = values.json === true;
233
+ if (command !== 'init' &&
234
+ (values.yes === true ||
235
+ values['no-interactive'] === true ||
236
+ values['print-choices'] === true ||
237
+ values['no-runtime'] === true)) {
238
+ failure(output, json, 'invalid_arguments', '--yes, --no-interactive, --print-choices and --no-runtime are only valid for init.');
239
+ return 2;
240
+ }
241
+ if (command !== 'host' && values.out !== undefined) {
242
+ failure(output, json, 'invalid_arguments', '--out is only valid for host.');
243
+ return 2;
244
+ }
245
+ if (command !== 'runtime' &&
246
+ (values.from !== undefined ||
247
+ values.tarball !== undefined ||
248
+ values.bind === true ||
249
+ values.force === true)) {
250
+ failure(output, json, 'invalid_arguments', '--from, --tarball, --bind and --force are only valid for runtime.');
251
+ return 2;
252
+ }
200
253
  let info;
201
254
  try {
202
255
  info = getPackageInfo();
@@ -339,6 +392,54 @@ export async function runCli(args, output) {
339
392
  installRuntime: values['install-runtime'] === true,
340
393
  runtimeTarball: values['runtime-tarball'] ?? null,
341
394
  runtimeVersion: values['runtime-version'] ?? null,
395
+ yes: values.yes === true || values['no-interactive'] === true,
396
+ printChoices: values['print-choices'] === true,
397
+ noRuntime: values['no-runtime'] === true,
398
+ }, output);
399
+ }
400
+ if (command === 'runtime') {
401
+ if (values.help) {
402
+ if (json) {
403
+ output.stdout(`${JSON.stringify({ ...info, commands: ['runtime'], usage: runtimeUsage })}\n`);
404
+ }
405
+ else {
406
+ output.stdout(runtimeUsage);
407
+ }
408
+ return 0;
409
+ }
410
+ if (values.version ||
411
+ values.config !== undefined ||
412
+ values['install-runtime'] === true ||
413
+ values['runtime-tarball'] !== undefined ||
414
+ values.root !== undefined ||
415
+ values.host !== undefined ||
416
+ values.repo !== undefined ||
417
+ values.query !== undefined ||
418
+ values.kind !== undefined ||
419
+ hasContinuityOptions(values)) {
420
+ failure(output, json, 'invalid_arguments', 'runtime accepts only --from, --tarball, --version, --apply, --bind, --force, --help, and --json.');
421
+ return 2;
422
+ }
423
+ const action = runtimeAction === 'install' ||
424
+ runtimeAction === 'status' ||
425
+ runtimeAction === 'verify' ||
426
+ runtimeAction === 'remove'
427
+ ? runtimeAction
428
+ : null;
429
+ return runRuntimeCommand({
430
+ cwd: process.cwd(),
431
+ action,
432
+ json,
433
+ from: values.from === 'self' ||
434
+ values.from === 'tarball' ||
435
+ values.from === 'registry'
436
+ ? values.from
437
+ : null,
438
+ tarball: values.tarball ?? null,
439
+ version: values['runtime-version'] ?? null,
440
+ apply: values.apply === true,
441
+ bind: values.bind === true,
442
+ force: values.force === true,
342
443
  }, output);
343
444
  }
344
445
  if (command === 'checkpoint') {
@@ -516,6 +617,7 @@ export async function runCli(args, output) {
516
617
  'plan',
517
618
  'install',
518
619
  'verify',
620
+ 'bundle',
519
621
  ];
520
622
  const action = allowedActions.find((item) => item === hostAction) ?? null;
521
623
  const parsedHost = hostNameSchema.safeParse(values.host);
@@ -548,7 +650,7 @@ export async function runCli(args, output) {
548
650
  return 0;
549
651
  }
550
652
  if (action === null || host === null || invalid) {
551
- failure(output, json, 'invalid_arguments', 'host requires detect/describe/plan/install/verify, --host, and action-compatible options.');
653
+ failure(output, json, 'invalid_arguments', 'host requires detect/describe/plan/install/verify/bundle, --host, and action-compatible options.');
552
654
  return 2;
553
655
  }
554
656
  return runHostCommand({
@@ -559,6 +661,7 @@ export async function runCli(args, output) {
559
661
  hostRoot: values['host-root'] ?? null,
560
662
  hostVersion: values['host-version'] ?? null,
561
663
  apply: values.apply === true,
664
+ out: values.out ?? null,
562
665
  }, output);
563
666
  }
564
667
  if (command !== null) {
@@ -23,6 +23,7 @@ export interface HostDetectionOptions {
23
23
  readonly selectedHosts: readonly string[];
24
24
  readonly observedAt?: string;
25
25
  }
26
+ export declare function executableOnPath(command: string, pathEnv: string): string | null;
26
27
  export declare function detectPassiveTool(command: string, pathEnv: string): PassiveToolObservation;
27
28
  /** Passive host discovery. It never starts a host or reads config contents. */
28
29
  export declare function detectPassiveHosts(options: HostDetectionOptions): readonly PassiveHostObservation[];
@@ -9,7 +9,7 @@ function evidence(path, observedAt) {
9
9
  scope: 'live-owned',
10
10
  };
11
11
  }
12
- function executableOnPath(command, pathEnv) {
12
+ export function executableOnPath(command, pathEnv) {
13
13
  for (const directory of pathEnv.split(delimiter)) {
14
14
  if (directory === '')
15
15
  continue;
@@ -0,0 +1,22 @@
1
+ import type { HostName } from '../../schemas/host.js';
2
+ export interface HostPluginBundle {
3
+ readonly host: HostName;
4
+ readonly outDir: string;
5
+ readonly files: readonly string[];
6
+ readonly pluginManifest: string;
7
+ readonly hookConfig: string | null;
8
+ readonly registration: readonly string[];
9
+ /** Plugin registration and native callability are not verified here. */
10
+ readonly verification: 'unsupported';
11
+ }
12
+ /**
13
+ * Writes a host-native plugin/package directory. This is an artifact export: it
14
+ * does not register the plugin with a host, and it never claims the host will
15
+ * load or trust it.
16
+ */
17
+ export declare function exportHostPluginBundle(options: {
18
+ readonly host: HostName;
19
+ readonly hostVersion: string;
20
+ readonly outDir: string;
21
+ readonly packageVersion: string;
22
+ }): Promise<HostPluginBundle>;
@@ -0,0 +1,114 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { getPackageRoot } from '../../package-info.js';
4
+ import { isPathInside } from '../../schemas/paths.js';
5
+ import { loadHostBundle } from './bundle.js';
6
+ import { renderHookConfigPatch } from './templates.js';
7
+ function pluginHookExpression(host) {
8
+ return host === 'claude'
9
+ ? '${CLAUDE_PLUGIN_ROOT}/hooks/pappra-hook.mjs'
10
+ : './hooks/pappra-hook.mjs';
11
+ }
12
+ function hostManifest(host, version) {
13
+ if (host === 'claude') {
14
+ return {
15
+ path: '.claude-plugin/plugin.json',
16
+ value: {
17
+ name: 'pappra',
18
+ version,
19
+ description: 'Portable workspace knowledge and continuity for coding agents.',
20
+ hooks: './hooks/hooks.json',
21
+ },
22
+ };
23
+ }
24
+ if (host === 'codex') {
25
+ return {
26
+ path: 'plugin.json',
27
+ value: {
28
+ name: 'pappra',
29
+ version,
30
+ description: 'Portable workspace knowledge and continuity for coding agents.',
31
+ hooks: './hooks/hooks.json',
32
+ },
33
+ };
34
+ }
35
+ return {
36
+ path: 'package.json',
37
+ value: {
38
+ name: '@phyzess/pappra-pi',
39
+ version,
40
+ description: 'Pappra Pi package: bounded lifecycle hooks and skill.',
41
+ type: 'module',
42
+ pi: {
43
+ extensions: ['./extensions/pappra/index.ts'],
44
+ skills: ['./skills'],
45
+ },
46
+ peerDependencies: { '@earendil-works/pi-coding-agent': '*' },
47
+ },
48
+ };
49
+ }
50
+ /**
51
+ * Writes a host-native plugin/package directory. This is an artifact export: it
52
+ * does not register the plugin with a host, and it never claims the host will
53
+ * load or trust it.
54
+ */
55
+ export async function exportHostPluginBundle(options) {
56
+ const outDir = resolve(options.outDir);
57
+ const payload = await loadHostBundle(options.host, options.packageVersion, {
58
+ hookSource: join(getPackageRoot(), 'dist/hosts/integration/pappra-hook.mjs'),
59
+ hookExpression: pluginHookExpression(options.host),
60
+ });
61
+ const written = [];
62
+ const writeEntry = async (path, bytes) => {
63
+ const target = join(outDir, ...path.split('/'));
64
+ if (!isPathInside(outDir, target)) {
65
+ throw new Error(`Unsafe plugin bundle path: ${path}`);
66
+ }
67
+ await mkdir(dirname(target), { recursive: true });
68
+ await writeFile(target, bytes);
69
+ written.push(path);
70
+ };
71
+ for (const [path, bytes] of payload.contents) {
72
+ // Plugin manifests point at hooks/hooks.json directly; the rendered
73
+ // templates are only an intermediate artifact.
74
+ if (options.host !== 'pi' && path.startsWith('templates/'))
75
+ continue;
76
+ await writeEntry(path, bytes);
77
+ }
78
+ const expression = pluginHookExpression(options.host);
79
+ if (options.host === 'pi') {
80
+ const entry = payload.contents.get('templates/pi/entry.ts');
81
+ if (entry === undefined) {
82
+ throw new Error('Pi plugin bundle is missing its extension entry.');
83
+ }
84
+ await writeEntry('extensions/pappra/index.ts', entry);
85
+ }
86
+ else {
87
+ const patch = renderHookConfigPatch(options.host, expression);
88
+ await writeEntry('hooks/hooks.json', Buffer.from(`${JSON.stringify(patch, null, 2)}\n`));
89
+ }
90
+ const manifest = hostManifest(options.host, options.packageVersion);
91
+ await writeEntry(manifest.path, Buffer.from(`${JSON.stringify(manifest.value, null, 2)}\n`));
92
+ return {
93
+ host: options.host,
94
+ outDir,
95
+ files: written.sort((left, right) => left.localeCompare(right)),
96
+ pluginManifest: manifest.path,
97
+ hookConfig: options.host === 'pi' ? null : 'hooks/hooks.json',
98
+ registration: options.host === 'claude'
99
+ ? [
100
+ 'Register the directory as a local plugin or marketplace entry; Pappra does not add it to host settings.',
101
+ 'Claude Code substitutes ${CLAUDE_PLUGIN_ROOT} at hook execution time.',
102
+ ]
103
+ : options.host === 'codex'
104
+ ? [
105
+ 'Package the directory as a Codex plugin; hook paths are relative to the plugin root.',
106
+ 'Review the plugin before trusting it; Pappra does not install or trust it.',
107
+ ]
108
+ : [
109
+ 'Install the directory as a Pi package, for example `pi install ./pappra-pi`.',
110
+ 'Pi installs package dependencies and requires project trust before loading.',
111
+ ],
112
+ verification: 'unsupported',
113
+ };
114
+ }
@@ -11,6 +11,9 @@ export declare function installPathForBundleFile(host: HostName, sourcePath: str
11
11
  */
12
12
  export declare function loadHostBundle(host: HostName, packageVersion: string, options?: {
13
13
  readonly assetsRoot?: string;
14
- readonly runtimeEntry?: string;
14
+ /** Absolute path of the bundled, dependency-free hook file. */
15
+ readonly hookSource?: string;
16
+ /** Command-line path expression used inside the host hook configuration. */
17
+ readonly hookExpression?: string;
15
18
  readonly observedAt?: string;
16
19
  }): Promise<HostBundlePayload>;