@themoltnet/legreffier 0.28.1 → 0.29.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.
Files changed (3) hide show
  1. package/README.md +39 -0
  2. package/dist/index.js +963 -59
  3. package/package.json +6 -5
package/README.md CHANGED
@@ -55,6 +55,43 @@ legreffier setup --name my-agent --agent codex
55
55
  legreffier setup --name my-agent --agent claude --agent codex
56
56
  ```
57
57
 
58
+ #### `legreffier port`
59
+
60
+ Port an existing agent identity into a new repository — **reuses** the
61
+ cryptographic identity, GitHub App, SSH keys, and gitconfig instead of
62
+ creating a new agent. Use this when the same agent should operate in
63
+ multiple repos under one GitHub App installation.
64
+
65
+ ```bash
66
+ legreffier port \
67
+ --name my-agent \
68
+ --from /path/to/source-repo/.moltnet/my-agent \
69
+ [--dir /path/to/target-repo] \
70
+ [--agent claude] [--agent codex] \
71
+ [--diary new|reuse|skip]
72
+ ```
73
+
74
+ Phases:
75
+
76
+ 1. **validate** — dry-run `repairConfig` on the source + presence checks
77
+ for PEM, ssh keys, `installation_id`, `client_id/secret`.
78
+ 2. **copy** — copies `moltnet.json`, the PEM, ssh keys (mode 0600 for
79
+ private material), and `allowed_signers` if present.
80
+ 3. **rewrite** — rewrites absolute paths in `moltnet.json` to the target
81
+ repo and regenerates `gitconfig` + `env`.
82
+ 4. **diary** — `reuse` carries `MOLTNET_DIARY_ID` from the source, `new`
83
+ strips it so the agent creates a fresh per-repo diary on activation,
84
+ `skip` leaves the env file untouched.
85
+ 5. **agent_setup** — writes per-agent MCP config, skills, settings, and
86
+ rules for each `--agent` (defaults to `claude`).
87
+ 6. **verify** — warning-only check that the GitHub App installation can
88
+ reach the current repo (detected from `git remote get-url origin`).
89
+ If the repo is out of scope, you'll see a link to the installation
90
+ settings page.
91
+
92
+ **Identity guard:** if the target already has a `.moltnet/<name>/` with
93
+ a different `identity_id`, port refuses to overwrite it.
94
+
58
95
  ### Options
59
96
 
60
97
  | Flag | Description | Default |
@@ -63,6 +100,8 @@ legreffier setup --name my-agent --agent claude --agent codex
63
100
  | `--agent, -a` | Agent type(s) to configure (repeatable) | Interactive prompt |
64
101
  | `--api-url` | MoltNet API URL | `https://api.themolt.net` |
65
102
  | `--dir` | Repository directory for config files | Current working directory |
103
+ | `--from` | (port) Source `.moltnet/<name>` dir | — |
104
+ | `--diary` | (port) Diary handling: new/reuse/skip | `new` |
66
105
 
67
106
  Supported agents: `claude`, `codex`.
68
107
 
package/dist/index.js CHANGED
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { parseArgs } from "node:util";
2
+ import { statSync } from "node:fs";
3
+ import { parseArgs, parseEnv } from "node:util";
3
4
  import { Box, Text, render, useApp, useInput } from "ink";
4
- import { execFileSync } from "node:child_process";
5
- import { dirname, join } from "node:path";
5
+ import { execFileSync, execSync } from "node:child_process";
6
+ import { basename, dirname, join } from "node:path";
6
7
  import { useEffect, useReducer, useRef, useState } from "react";
7
8
  import { jsx, jsxs } from "react/jsx-runtime";
8
9
  import figlet from "figlet";
10
+ import { createSign } from "node:crypto";
9
11
  import { createHash, randomBytes } from "crypto";
10
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
12
+ import { access, chmod, copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
11
13
  import { homedir } from "node:os";
12
14
  import { parse, stringify } from "smol-toml";
13
15
  import open from "open";
@@ -7192,6 +7194,13 @@ async function writeMcpConfig(mcpConfig, dir) {
7192
7194
  }
7193
7195
  //#endregion
7194
7196
  //#region ../../libs/sdk/src/credentials.ts
7197
+ /**
7198
+ * Derive the MCP URL from an API URL.
7199
+ * e.g. "https://api.themolt.net" → "https://mcp.themolt.net/mcp"
7200
+ */
7201
+ function deriveMcpUrl(apiUrl) {
7202
+ return apiUrl.replace("://api.", "://mcp.") + "/mcp";
7203
+ }
7195
7204
  function getConfigDir() {
7196
7205
  return join(homedir(), ".config", "moltnet");
7197
7206
  }
@@ -7241,6 +7250,119 @@ async function updateConfigSection(section, data, configDir) {
7241
7250
  await writeConfig(config, configDir);
7242
7251
  }
7243
7252
  //#endregion
7253
+ //#region ../../libs/sdk/src/repair.ts
7254
+ /**
7255
+ * Validate and optionally repair a MoltNet config.
7256
+ *
7257
+ * Checks required fields, detects stale file paths, and migrates
7258
+ * `credentials.json` to `moltnet.json` when found.
7259
+ *
7260
+ * Pass `dryRun: true` to report issues without writing changes.
7261
+ */
7262
+ async function repairConfig(opts) {
7263
+ const dir = opts?.configDir ?? getConfigDir();
7264
+ const issues = [];
7265
+ let config = await tryReadJson(join(dir, "moltnet.json"));
7266
+ if (!config) {
7267
+ const legacy = await tryReadJson(join(dir, "credentials.json"));
7268
+ if (legacy) {
7269
+ config = legacy;
7270
+ issues.push({
7271
+ field: "file",
7272
+ problem: "using deprecated credentials.json — will migrate to moltnet.json",
7273
+ action: "migrate"
7274
+ });
7275
+ if (!opts?.dryRun) await writeConfig(config, dir);
7276
+ } else return {
7277
+ issues: [],
7278
+ config: null
7279
+ };
7280
+ }
7281
+ validateConfig(config, issues);
7282
+ await checkFilePaths(config, issues);
7283
+ if (!config.endpoints.mcp && config.endpoints.api) {
7284
+ config.endpoints.mcp = deriveMcpUrl(config.endpoints.api);
7285
+ issues.push({
7286
+ field: "endpoints.mcp",
7287
+ problem: "missing — derived from API endpoint",
7288
+ action: "fixed"
7289
+ });
7290
+ }
7291
+ if (issues.some((i) => i.action === "fixed") && !opts?.dryRun) await writeConfig(config, dir);
7292
+ return {
7293
+ issues,
7294
+ config
7295
+ };
7296
+ }
7297
+ function validateConfig(config, issues) {
7298
+ if (!config.identity_id) issues.push({
7299
+ field: "identity_id",
7300
+ problem: "missing",
7301
+ action: "warning"
7302
+ });
7303
+ if (!config.keys.public_key) issues.push({
7304
+ field: "keys.public_key",
7305
+ problem: "missing",
7306
+ action: "warning"
7307
+ });
7308
+ if (!config.keys.private_key) issues.push({
7309
+ field: "keys.private_key",
7310
+ problem: "missing",
7311
+ action: "warning"
7312
+ });
7313
+ if (config.keys.public_key && !config.keys.public_key.startsWith("ed25519:")) issues.push({
7314
+ field: "keys.public_key",
7315
+ problem: "missing 'ed25519:' prefix",
7316
+ action: "warning"
7317
+ });
7318
+ if (!config.endpoints.api) issues.push({
7319
+ field: "endpoints.api",
7320
+ problem: "missing",
7321
+ action: "warning"
7322
+ });
7323
+ if (config.github?.app_id && !/^\d+$/.test(config.github.app_id)) issues.push({
7324
+ field: "github.app_id",
7325
+ problem: `not a numeric GitHub App ID (got "${config.github.app_id}") — likely the slug; refetch via \`moltnet config init-from-env\` or re-run \`legreffier\``,
7326
+ action: "warning"
7327
+ });
7328
+ }
7329
+ async function checkFilePaths(config, issues) {
7330
+ const checks = [];
7331
+ if (config.ssh?.private_key_path) checks.push({
7332
+ field: "ssh.private_key_path",
7333
+ path: config.ssh.private_key_path
7334
+ });
7335
+ if (config.ssh?.public_key_path) checks.push({
7336
+ field: "ssh.public_key_path",
7337
+ path: config.ssh.public_key_path
7338
+ });
7339
+ if (config.git?.config_path) checks.push({
7340
+ field: "git.config_path",
7341
+ path: config.git.config_path
7342
+ });
7343
+ if (config.github?.private_key_path) checks.push({
7344
+ field: "github.private_key_path",
7345
+ path: config.github.private_key_path
7346
+ });
7347
+ for (const { field, path } of checks) try {
7348
+ await access(path);
7349
+ } catch {
7350
+ issues.push({
7351
+ field,
7352
+ problem: `file not found: ${path}`,
7353
+ action: "warning"
7354
+ });
7355
+ }
7356
+ }
7357
+ async function tryReadJson(path) {
7358
+ try {
7359
+ const content = await readFile(path, "utf-8");
7360
+ return JSON.parse(content);
7361
+ } catch {
7362
+ return null;
7363
+ }
7364
+ }
7365
+ //#endregion
7244
7366
  //#region ../../libs/sdk/src/ssh.ts
7245
7367
  /**
7246
7368
  * Export the agent's Ed25519 key pair as OpenSSH key files.
@@ -7424,6 +7546,20 @@ function buildGhTokenRule(agentName) {
7424
7546
  "The token is cached locally (~1 hour lifetime, 5-min expiry buffer),",
7425
7547
  "so repeated calls are fast after the first API hit.",
7426
7548
  "",
7549
+ "## Worktree warning",
7550
+ "",
7551
+ `\`GIT_CONFIG_GLOBAL\` may be a **relative path** (e.g. \`.moltnet/${agentName}/gitconfig\`).`,
7552
+ "In git worktrees the CWD differs from the main worktree root, so `$(dirname \"$GIT_CONFIG_GLOBAL\")`",
7553
+ "resolves incorrectly and `no credentials found` is printed — the command then falls back to your",
7554
+ "personal `gh` token silently.",
7555
+ "",
7556
+ "**Always resolve to an absolute path first:**",
7557
+ "",
7558
+ "```bash",
7559
+ "CREDS=\"$(cd \"$(dirname \"$GIT_CONFIG_GLOBAL\")\" && pwd)/moltnet.json\"",
7560
+ "GH_TOKEN=$(npx @themoltnet/cli github token --credentials \"$CREDS\") gh <command>",
7561
+ "```",
7562
+ "",
7427
7563
  "## Allowed `gh` subcommands",
7428
7564
  "",
7429
7565
  "The GitHub App only has these permissions:",
@@ -7517,6 +7653,28 @@ function buildCodexRules(_agentName) {
7517
7653
  " decision = \"allow\",",
7518
7654
  ")",
7519
7655
  "",
7656
+ "# GitHub CLI — read-only subcommands (write ops prompt the user)",
7657
+ "prefix_rule(",
7658
+ " pattern = [\"gh\", \"pr\", \"view\"],",
7659
+ " decision = \"allow\",",
7660
+ ")",
7661
+ "prefix_rule(",
7662
+ " pattern = [\"gh\", \"pr\", \"list\"],",
7663
+ " decision = \"allow\",",
7664
+ ")",
7665
+ "prefix_rule(",
7666
+ " pattern = [\"gh\", \"issue\", \"view\"],",
7667
+ " decision = \"allow\",",
7668
+ ")",
7669
+ "prefix_rule(",
7670
+ " pattern = [\"gh\", \"issue\", \"list\"],",
7671
+ " decision = \"allow\",",
7672
+ ")",
7673
+ "prefix_rule(",
7674
+ " pattern = [\"gh\", \"repo\", \"view\"],",
7675
+ " decision = \"allow\",",
7676
+ ")",
7677
+ "",
7520
7678
  "# Worktree symlink creation",
7521
7679
  "prefix_rule(",
7522
7680
  " pattern = [\"ln\", \"-s\"],",
@@ -7559,7 +7717,7 @@ function toEnvPrefix(agentName) {
7559
7717
  return agentName.toUpperCase().replace(/[^A-Z0-9]/g, "_");
7560
7718
  }
7561
7719
  /** Merge agent env vars into .claude/settings.local.json, preserving existing entries. */
7562
- async function writeSettingsLocal({ repoDir, agentName, appSlug, pemPath, installationId, clientId, clientSecret }) {
7720
+ async function writeSettingsLocal({ repoDir, agentName, appId, pemPath, installationId, clientId, clientSecret }) {
7563
7721
  const dir = join(repoDir, ".claude");
7564
7722
  await mkdir(dir, { recursive: true });
7565
7723
  const filePath = join(dir, "settings.local.json");
@@ -7582,7 +7740,7 @@ async function writeSettingsLocal({ repoDir, agentName, appSlug, pemPath, instal
7582
7740
  },
7583
7741
  env: {
7584
7742
  ...existing.env,
7585
- [`${prefix}_GITHUB_APP_ID`]: appSlug,
7743
+ [`${prefix}_GITHUB_APP_ID`]: appId,
7586
7744
  [`${prefix}_GITHUB_APP_PRIVATE_KEY_PATH`]: pemPath,
7587
7745
  [`${prefix}_GITHUB_APP_INSTALLATION_ID`]: installationId,
7588
7746
  [`${prefix}_CLIENT_ID`]: clientId,
@@ -7612,7 +7770,7 @@ var ClaudeAdapter = class {
7612
7770
  await writeSettingsLocal({
7613
7771
  repoDir: opts.repoDir,
7614
7772
  agentName: opts.agentName,
7615
- appSlug: opts.appSlug,
7773
+ appId: opts.appId,
7616
7774
  pemPath: opts.pemPath,
7617
7775
  installationId: opts.installationId,
7618
7776
  clientId: opts.clientId,
@@ -7672,6 +7830,13 @@ var adapters = {
7672
7830
  };
7673
7831
  //#endregion
7674
7832
  //#region src/env-file.ts
7833
+ /**
7834
+ * Parse a dotenv-format string using Node.js built-in `util.parseEnv`.
7835
+ * Handles quoting, comments, and blank lines.
7836
+ */
7837
+ function parseEnvFile(content) {
7838
+ return parseEnv(content);
7839
+ }
7675
7840
  function q(v) {
7676
7841
  return `'${v.replace(/'/g, "'\\''")}'`;
7677
7842
  }
@@ -7686,10 +7851,11 @@ async function writeEnvFile(opts) {
7686
7851
  const managedEntries = [
7687
7852
  [`${opts.prefix}_CLIENT_ID`, q(opts.clientId)],
7688
7853
  [`${opts.prefix}_CLIENT_SECRET`, q(opts.clientSecret)],
7689
- [`${opts.prefix}_GITHUB_APP_ID`, q(opts.appSlug)],
7854
+ [`${opts.prefix}_GITHUB_APP_ID`, q(opts.appId)],
7690
7855
  [`${opts.prefix}_GITHUB_APP_PRIVATE_KEY_PATH`, q(opts.pemPath)],
7691
7856
  [`${opts.prefix}_GITHUB_APP_INSTALLATION_ID`, q(opts.installationId)],
7692
- ["GIT_CONFIG_GLOBAL", q(`.moltnet/${opts.agentName}/gitconfig`)]
7857
+ ["GIT_CONFIG_GLOBAL", q(`.moltnet/${opts.agentName}/gitconfig`)],
7858
+ ["MOLTNET_AGENT_NAME", q(opts.agentName)]
7693
7859
  ];
7694
7860
  const managedKeys = new Set(managedEntries.map(([k]) => k));
7695
7861
  let existingLines = [];
@@ -7746,7 +7912,7 @@ async function clearState(configDir) {
7746
7912
  //#endregion
7747
7913
  //#region src/phases/agentSetup.ts
7748
7914
  async function runAgentSetupPhase(opts) {
7749
- const { apiUrl, repoDir, configDir, agentName, agentTypes, publicKey, fingerprint, appSlug, pemPath, installationId, identityId, clientId, clientSecret, dispatch } = opts;
7915
+ const { apiUrl, repoDir, configDir, agentName, agentTypes, publicKey, fingerprint, appId, appSlug, pemPath, installationId, identityId, clientId, clientSecret, org, dispatch } = opts;
7750
7916
  dispatch({
7751
7917
  type: "phase",
7752
7918
  phase: "agent_setup"
@@ -7769,10 +7935,11 @@ async function runAgentSetupPhase(opts) {
7769
7935
  mcp: apiUrl.replace("://api.", "://mcp.") + "/mcp"
7770
7936
  },
7771
7937
  github: {
7772
- app_id: appSlug,
7938
+ app_id: appId,
7773
7939
  app_slug: appSlug,
7774
7940
  installation_id: installationId,
7775
- private_key_path: pemPath
7941
+ private_key_path: pemPath,
7942
+ ...org ? { org } : {}
7776
7943
  }
7777
7944
  }, configDir);
7778
7945
  const prefix = toEnvPrefix(agentName);
@@ -7784,6 +7951,7 @@ async function runAgentSetupPhase(opts) {
7784
7951
  clientId,
7785
7952
  clientSecret,
7786
7953
  appSlug,
7954
+ appId,
7787
7955
  pemPath,
7788
7956
  installationId
7789
7957
  };
@@ -7819,7 +7987,7 @@ async function runAgentSetupPhase(opts) {
7819
7987
  prefix,
7820
7988
  clientId,
7821
7989
  clientSecret,
7822
- appSlug,
7990
+ appId,
7823
7991
  pemPath,
7824
7992
  installationId
7825
7993
  });
@@ -7878,35 +8046,6 @@ async function suggestAppNames(appName) {
7878
8046
  available: await checkAppNameAvailable(name)
7879
8047
  })))).filter((r) => r.available).map((r) => r.name);
7880
8048
  }
7881
- /**
7882
- * Look up GitHub bot user and derive noreply email.
7883
- * Tries <appSlug>[bot] first (exists post-installation), then falls back to
7884
- * plain <appSlug> (exists right after app creation, pre-installation).
7885
- *
7886
- * Retries with exponential backoff because GitHub's public /users API
7887
- * may not index a newly created app account immediately.
7888
- */
7889
- async function lookupBotUser(appSlug, { maxRetries = 5, baseDelayMs = 2e3 } = {}) {
7890
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
7891
- for (const username of [`${appSlug}[bot]`, appSlug]) {
7892
- const res = await fetch(`https://api.github.com/users/${encodeURIComponent(username)}`, { headers: GITHUB_HEADERS });
7893
- if (res.ok) {
7894
- const data = await res.json();
7895
- return {
7896
- id: data.id,
7897
- email: `${data.id}+${data.login}@users.noreply.github.com`
7898
- };
7899
- }
7900
- }
7901
- if (attempt < maxRetries) {
7902
- const delayMs = baseDelayMs * 2 ** attempt;
7903
- await new Promise((resolve) => {
7904
- setTimeout(resolve, delayMs);
7905
- });
7906
- }
7907
- }
7908
- throw new Error(`GitHub user lookup failed for app "${appSlug}"`);
7909
- }
7910
8049
  /** Write GitHub App PEM to <configDir>/<appSlug>.pem (mode 0o600). */
7911
8050
  async function writePem(pem, appSlug, configDir) {
7912
8051
  await mkdir(configDir, { recursive: true });
@@ -7954,6 +8093,7 @@ async function runGithubAppPhase(opts) {
7954
8093
  appSlug: existingConfig.github.app_slug ?? ""
7955
8094
  });
7956
8095
  return {
8096
+ appId: existingConfig.github.app_id,
7957
8097
  appSlug: existingConfig.github.app_slug ?? "",
7958
8098
  pemPath: existingConfig.github.private_key_path,
7959
8099
  installationId: existingConfig.github.installation_id,
@@ -7972,6 +8112,7 @@ async function runGithubAppPhase(opts) {
7972
8112
  appSlug: existingState.appSlug
7973
8113
  });
7974
8114
  return {
8115
+ appId: existingState.appId,
7975
8116
  appSlug: existingState.appSlug,
7976
8117
  pemPath,
7977
8118
  installationId: "",
@@ -8023,6 +8164,7 @@ async function runGithubAppPhase(opts) {
8023
8164
  status: "done"
8024
8165
  });
8025
8166
  return {
8167
+ appId: ghCreds.appId,
8026
8168
  appSlug: ghCreds.appSlug,
8027
8169
  pemPath,
8028
8170
  installationId: "",
@@ -8030,6 +8172,136 @@ async function runGithubAppPhase(opts) {
8030
8172
  };
8031
8173
  }
8032
8174
  //#endregion
8175
+ //#region ../github-agent/src/bot-user.ts
8176
+ var GITHUB_API_BASE_URL = "https://api.github.com";
8177
+ /**
8178
+ * Look up the shadow bot user associated with a GitHub App.
8179
+ * Every GitHub App gets a bot user account (`<slug>[bot]`).
8180
+ * This endpoint is public — no authentication required.
8181
+ *
8182
+ * Tries `<appSlug>[bot]` first (exists post-installation), then falls
8183
+ * back to plain `<appSlug>` (exists right after app creation, pre-install).
8184
+ *
8185
+ * @returns The bot user ID and login
8186
+ */
8187
+ async function lookupBotUser(appSlug, opts = {}) {
8188
+ const { apiBaseUrl = GITHUB_API_BASE_URL, maxRetries = 0, baseDelayMs = 2e3 } = opts;
8189
+ const headers = {
8190
+ Accept: "application/vnd.github+json",
8191
+ "X-GitHub-Api-Version": "2022-11-28"
8192
+ };
8193
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
8194
+ for (const username of [`${appSlug}[bot]`, appSlug]) {
8195
+ const url = `${apiBaseUrl}/users/${encodeURIComponent(username)}`;
8196
+ const response = await fetch(url, { headers });
8197
+ if (response.ok) {
8198
+ const data = await response.json();
8199
+ return {
8200
+ id: data.id,
8201
+ login: data.login
8202
+ };
8203
+ }
8204
+ }
8205
+ if (attempt < maxRetries) {
8206
+ const delayMs = baseDelayMs * 2 ** attempt;
8207
+ await new Promise((resolve) => {
8208
+ setTimeout(resolve, delayMs);
8209
+ });
8210
+ }
8211
+ }
8212
+ throw new Error(`GitHub user lookup failed for app "${appSlug}"`);
8213
+ }
8214
+ /**
8215
+ * Build the GitHub noreply email for a bot user.
8216
+ * Format: `<bot-user-id>+<slug>[bot]@users.noreply.github.com`
8217
+ */
8218
+ function buildBotEmail(botUserId, appSlug) {
8219
+ return `${botUserId}+${appSlug}[bot]@users.noreply.github.com`;
8220
+ }
8221
+ //#endregion
8222
+ //#region ../github-agent/src/token.ts
8223
+ /**
8224
+ * Create a JWT signed with the GitHub App's RSA private key.
8225
+ */
8226
+ function createAppJWT(appId, privateKeyPem) {
8227
+ const now = Math.floor(Date.now() / 1e3);
8228
+ const header = Buffer.from(JSON.stringify({
8229
+ alg: "RS256",
8230
+ typ: "JWT"
8231
+ })).toString("base64url");
8232
+ const payload = Buffer.from(JSON.stringify({
8233
+ iss: appId,
8234
+ iat: now - 60,
8235
+ exp: now + 600
8236
+ })).toString("base64url");
8237
+ const sign = createSign("RSA-SHA256");
8238
+ sign.update(`${header}.${payload}`);
8239
+ return `${header}.${payload}.${sign.sign(privateKeyPem, "base64url")}`;
8240
+ }
8241
+ /** Minimum remaining validity before we consider a cached token expired. */
8242
+ var EXPIRY_BUFFER_MS = 300 * 1e3;
8243
+ /**
8244
+ * Read a cached token from disk. Returns null if missing, corrupt, or expired.
8245
+ */
8246
+ async function readTokenCache(cachePath) {
8247
+ try {
8248
+ const raw = await readFile(cachePath, "utf-8");
8249
+ const cached = JSON.parse(raw);
8250
+ if (!cached.token || !cached.expires_at) return null;
8251
+ const expiresAt = new Date(cached.expires_at).getTime();
8252
+ if (Date.now() + EXPIRY_BUFFER_MS >= expiresAt) return null;
8253
+ return {
8254
+ token: cached.token,
8255
+ expiresAt: cached.expires_at
8256
+ };
8257
+ } catch {
8258
+ return null;
8259
+ }
8260
+ }
8261
+ /**
8262
+ * Write a token to the cache file (best-effort).
8263
+ */
8264
+ async function writeTokenCache(cachePath, token, expiresAt) {
8265
+ const cache = {
8266
+ token,
8267
+ expires_at: expiresAt
8268
+ };
8269
+ try {
8270
+ await writeFile(cachePath, JSON.stringify(cache), { mode: 384 });
8271
+ } catch {}
8272
+ }
8273
+ /**
8274
+ * Exchange a GitHub App JWT for an installation access token.
8275
+ * Uses a file-based cache next to the private key to avoid
8276
+ * hitting the GitHub API on every call.
8277
+ */
8278
+ async function getInstallationToken(opts) {
8279
+ const cachePath = join(dirname(opts.privateKeyPath), "gh-token-cache.json");
8280
+ const cached = await readTokenCache(cachePath);
8281
+ if (cached) return cached;
8282
+ const privateKeyPem = await readFile(opts.privateKeyPath, "utf-8");
8283
+ const jwt = createAppJWT(opts.appId, privateKeyPem);
8284
+ const response = await fetch(`https://api.github.com/app/installations/${opts.installationId}/access_tokens`, {
8285
+ method: "POST",
8286
+ headers: {
8287
+ Authorization: `Bearer ${jwt}`,
8288
+ Accept: "application/vnd.github+json",
8289
+ "X-GitHub-Api-Version": "2022-11-28"
8290
+ }
8291
+ });
8292
+ if (!response.ok) {
8293
+ const body = await response.text();
8294
+ throw new Error(`GitHub API error (${response.status}): ${body}`);
8295
+ }
8296
+ const data = await response.json();
8297
+ const result = {
8298
+ token: data.token,
8299
+ expiresAt: data.expires_at
8300
+ };
8301
+ await writeTokenCache(cachePath, result.token, result.expiresAt);
8302
+ return result;
8303
+ }
8304
+ //#endregion
8033
8305
  //#region src/phases/gitSetup.ts
8034
8306
  async function runGitSetupPhase(opts) {
8035
8307
  const { configDir, agentName, appSlug, dispatch } = opts;
@@ -8051,18 +8323,17 @@ async function runGitSetupPhase(opts) {
8051
8323
  status: "running"
8052
8324
  });
8053
8325
  const { privatePath } = await exportSSHKey({ configDir });
8054
- const botUser = await lookupBotUser(appSlug);
8055
- const gitConfigPath = await writeGitConfig({
8056
- configDir,
8057
- name: agentName,
8058
- email: botUser.email,
8059
- sshKeyPath: privatePath
8060
- });
8326
+ const email = buildBotEmail((await lookupBotUser(appSlug, { maxRetries: 5 })).id, appSlug);
8061
8327
  await updateConfigSection("git", {
8062
8328
  name: agentName,
8063
- email: botUser.email,
8329
+ email,
8064
8330
  signing: true,
8065
- config_path: gitConfigPath
8331
+ config_path: await writeGitConfig({
8332
+ configDir,
8333
+ name: agentName,
8334
+ email,
8335
+ sshKeyPath: privatePath
8336
+ })
8066
8337
  }, configDir);
8067
8338
  dispatch({
8068
8339
  type: "step",
@@ -8073,7 +8344,7 @@ async function runGitSetupPhase(opts) {
8073
8344
  //#endregion
8074
8345
  //#region src/phases/identity.ts
8075
8346
  async function runIdentityPhase(opts) {
8076
- const { apiUrl, agentName, configDir, dispatch } = opts;
8347
+ const { apiUrl, agentName, configDir, org, dispatch } = opts;
8077
8348
  const existingConfig = await readConfig(configDir);
8078
8349
  const existingState = await readState(configDir);
8079
8350
  if (existingConfig?.keys?.public_key && existingConfig?.oauth2?.client_id) {
@@ -8180,7 +8451,8 @@ async function runIdentityPhase(opts) {
8180
8451
  const started = await startOnboarding(apiUrl, {
8181
8452
  publicKey: kp.publicKey,
8182
8453
  fingerprint: kp.fingerprint,
8183
- agentName
8454
+ agentName,
8455
+ ...org ? { org } : {}
8184
8456
  });
8185
8457
  await writeState({
8186
8458
  workflowId: started.workflowId,
@@ -8572,7 +8844,7 @@ function ProgressPhase({ state, name, showManifestFallback, showInstallFallback
8572
8844
  ]
8573
8845
  });
8574
8846
  }
8575
- function InitApp({ name, agents: agentsProp, apiUrl, dir = process.cwd() }) {
8847
+ function InitApp({ name, agents: agentsProp, apiUrl, dir = process.cwd(), org }) {
8576
8848
  const { exit } = useApp();
8577
8849
  const [state, dispatch] = useReducer(uiReducer, {
8578
8850
  phase: "disclaimer",
@@ -8614,6 +8886,7 @@ function InitApp({ name, agents: agentsProp, apiUrl, dir = process.cwd() }) {
8614
8886
  apiUrl,
8615
8887
  agentName: name,
8616
8888
  configDir,
8889
+ org,
8617
8890
  dispatch
8618
8891
  });
8619
8892
  const githubApp = await runGithubAppPhase({
@@ -8648,12 +8921,14 @@ function InitApp({ name, agents: agentsProp, apiUrl, dir = process.cwd() }) {
8648
8921
  agentTypes: selectedAgents,
8649
8922
  publicKey: identity.publicKey,
8650
8923
  fingerprint: identity.fingerprint,
8924
+ appId: githubApp.appId,
8651
8925
  appSlug: githubApp.appSlug,
8652
8926
  pemPath: githubApp.pemPath,
8653
8927
  installationId: installation.installationId || githubApp.installationId,
8654
8928
  identityId: installation.identityId,
8655
8929
  clientId: installation.clientId || identity.clientId,
8656
8930
  clientSecret: installation.clientSecret || identity.clientSecret,
8931
+ org,
8657
8932
  dispatch
8658
8933
  });
8659
8934
  const mcpUrl = apiUrl.replace("://api.", "://mcp.") + "/mcp";
@@ -8707,6 +8982,580 @@ function InitApp({ name, agents: agentsProp, apiUrl, dir = process.cwd() }) {
8707
8982
  });
8708
8983
  }
8709
8984
  //#endregion
8985
+ //#region src/phases/portValidate.ts
8986
+ /**
8987
+ * Validate a source `.moltnet/<agent>/` directory for porting.
8988
+ *
8989
+ * Runs the generic `repairConfig({ dryRun: true })` checks, then adds
8990
+ * port-specific blocking checks:
8991
+ * - `identity_id`, `keys.fingerprint`, `oauth2.client_id/secret`
8992
+ * - `github.app_id` present and numeric, `github.app_slug`, `github.installation_id`
8993
+ * - `ssh.private_key_path`, `ssh.public_key_path`, `git.config_path` set
8994
+ * - `github.private_key_path` set
8995
+ * - All four absolute paths (ssh priv/pub, git config, github pem) exist on disk
8996
+ *
8997
+ * Throws if `moltnet.json` is missing or unreadable — nothing to port.
8998
+ */
8999
+ async function runPortValidatePhase(opts) {
9000
+ const { sourceDir } = opts;
9001
+ const config = await readConfig(sourceDir);
9002
+ if (!config) throw new Error(`No moltnet.json found in ${sourceDir} — nothing to port. Run \`legreffier\` on a repo first to create a source identity.`);
9003
+ const { issues: baseIssues } = await repairConfig({
9004
+ configDir: sourceDir,
9005
+ dryRun: true
9006
+ });
9007
+ const issues = [...baseIssues];
9008
+ if (!config.oauth2?.client_id) issues.push({
9009
+ field: "oauth2.client_id",
9010
+ problem: "missing — required for port",
9011
+ action: "warning"
9012
+ });
9013
+ if (!config.oauth2?.client_secret) issues.push({
9014
+ field: "oauth2.client_secret",
9015
+ problem: "missing — required for port",
9016
+ action: "warning"
9017
+ });
9018
+ if (!config.keys?.fingerprint) issues.push({
9019
+ field: "keys.fingerprint",
9020
+ problem: "missing — required for port",
9021
+ action: "warning"
9022
+ });
9023
+ if (!config.github?.app_id) issues.push({
9024
+ field: "github.app_id",
9025
+ problem: "missing — required for port",
9026
+ action: "warning"
9027
+ });
9028
+ if (!config.github?.app_slug) issues.push({
9029
+ field: "github.app_slug",
9030
+ problem: "missing — required for port (used for PEM filename and bot lookup)",
9031
+ action: "warning"
9032
+ });
9033
+ if (!config.github?.installation_id) issues.push({
9034
+ field: "github.installation_id",
9035
+ problem: "missing — required for port",
9036
+ action: "warning"
9037
+ });
9038
+ if (!config.github?.private_key_path) issues.push({
9039
+ field: "github.private_key_path",
9040
+ problem: "missing — required for port",
9041
+ action: "warning"
9042
+ });
9043
+ if (!config.ssh?.private_key_path) issues.push({
9044
+ field: "ssh.private_key_path",
9045
+ problem: "missing — required for port",
9046
+ action: "warning"
9047
+ });
9048
+ if (!config.ssh?.public_key_path) issues.push({
9049
+ field: "ssh.public_key_path",
9050
+ problem: "missing — required for port",
9051
+ action: "warning"
9052
+ });
9053
+ if (!config.git?.config_path) issues.push({
9054
+ field: "git.config_path",
9055
+ problem: "missing — required for port",
9056
+ action: "warning"
9057
+ });
9058
+ return {
9059
+ config,
9060
+ issues,
9061
+ canProceed: issues.filter((i) => i.action === "warning").length === 0
9062
+ };
9063
+ }
9064
+ /** Check whether a file is readable. Used by portCopy for optional files. */
9065
+ async function fileExists(path) {
9066
+ try {
9067
+ await access(path);
9068
+ return true;
9069
+ } catch {
9070
+ return false;
9071
+ }
9072
+ }
9073
+ //#endregion
9074
+ //#region src/phases/portCopy.ts
9075
+ /**
9076
+ * Copy private material from a source `.moltnet/<agent>/` into the target.
9077
+ *
9078
+ * Copies:
9079
+ * - `moltnet.json` (0600) — will later be rewritten in P3 with absolute paths
9080
+ * - GitHub App PEM (0600) — at `<sourceDir>/<appSlug>.pem` by convention
9081
+ * - SSH private key (0600) and public key (0644)
9082
+ * - `allowed_signers` if present (0644) — optional, warning only
9083
+ *
9084
+ * Assumes `runPortValidatePhase` has been run and `canProceed` was true,
9085
+ * so required fields and files are known to exist.
9086
+ */
9087
+ async function runPortCopyPhase(opts) {
9088
+ const { sourceDir, targetDir, config } = opts;
9089
+ const copied = [];
9090
+ const warnings = [];
9091
+ await mkdir(targetDir, { recursive: true });
9092
+ const targetConfig = join(targetDir, "moltnet.json");
9093
+ await copyFile(join(sourceDir, "moltnet.json"), targetConfig);
9094
+ await chmod(targetConfig, 384);
9095
+ copied.push(targetConfig);
9096
+ if (!config.github?.private_key_path) throw new Error("github.private_key_path missing — run portValidate first");
9097
+ const targetPem = join(targetDir, basename(config.github.private_key_path));
9098
+ await copyFile(config.github.private_key_path, targetPem);
9099
+ await chmod(targetPem, 384);
9100
+ copied.push(targetPem);
9101
+ if (!config.ssh?.private_key_path || !config.ssh?.public_key_path) throw new Error("ssh key paths missing — run portValidate first");
9102
+ const sshDir = join(targetDir, "ssh");
9103
+ await mkdir(sshDir, { recursive: true });
9104
+ const targetSshPriv = join(sshDir, basename(config.ssh.private_key_path));
9105
+ await copyFile(config.ssh.private_key_path, targetSshPriv);
9106
+ await chmod(targetSshPriv, 384);
9107
+ copied.push(targetSshPriv);
9108
+ const targetSshPub = join(sshDir, basename(config.ssh.public_key_path));
9109
+ await copyFile(config.ssh.public_key_path, targetSshPub);
9110
+ await chmod(targetSshPub, 420);
9111
+ copied.push(targetSshPub);
9112
+ const sourceAllowed = join(dirname(config.ssh.private_key_path), "allowed_signers");
9113
+ if (await fileExists(sourceAllowed)) {
9114
+ const targetAllowed = join(sshDir, "allowed_signers");
9115
+ await copyFile(sourceAllowed, targetAllowed);
9116
+ await chmod(targetAllowed, 420);
9117
+ copied.push(targetAllowed);
9118
+ } else warnings.push(`allowed_signers not found at ${sourceAllowed} — skipping (optional)`);
9119
+ return {
9120
+ copied,
9121
+ warnings
9122
+ };
9123
+ }
9124
+ //#endregion
9125
+ //#region src/phases/portDiary.ts
9126
+ /**
9127
+ * Standard UUID v4-ish shape. Diary IDs are server-issued UUIDs; anything
9128
+ * else in this field is either stale data, a mis-parsed env line, or a
9129
+ * crafted injection attempt (e.g. embedded newlines) and must be rejected
9130
+ * before we echo it into the target env file.
9131
+ */
9132
+ var DIARY_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9133
+ /**
9134
+ * Read `MOLTNET_DIARY_ID` from a source env file.
9135
+ * Returns null if the file or key is absent, or if the stored value is
9136
+ * not a valid UUID (defensive: never propagate malformed data).
9137
+ */
9138
+ async function readSourceDiaryId(sourceDir) {
9139
+ try {
9140
+ const raw = parseEnvFile(await readFile(join(sourceDir, "env"), "utf-8")).MOLTNET_DIARY_ID;
9141
+ if (!raw || !DIARY_ID_RE.test(raw)) return null;
9142
+ return raw;
9143
+ } catch {
9144
+ return null;
9145
+ }
9146
+ }
9147
+ /**
9148
+ * Apply the chosen diary mode to the target env file:
9149
+ *
9150
+ * - `reuse`: persist the source MOLTNET_DIARY_ID in the target env
9151
+ * - `new`: strip MOLTNET_DIARY_ID from the target env (legreffier skill
9152
+ * will resolve it at session start via `diaries_list` / create)
9153
+ * - `skip`: leave the target env untouched
9154
+ *
9155
+ * Assumes the target env file has already been written by `portRewrite`.
9156
+ */
9157
+ async function runPortDiaryPhase(opts) {
9158
+ const { targetDir, mode, sourceDiaryId } = opts;
9159
+ const envPath = join(targetDir, "env");
9160
+ if (mode === "skip") return {
9161
+ mode,
9162
+ diaryId: null,
9163
+ modified: false
9164
+ };
9165
+ let content = "";
9166
+ try {
9167
+ content = await readFile(envPath, "utf-8");
9168
+ } catch {
9169
+ return {
9170
+ mode,
9171
+ diaryId: null,
9172
+ modified: false
9173
+ };
9174
+ }
9175
+ const lines = content.split("\n");
9176
+ const diaryLineRe = /^\s*MOLTNET_DIARY_ID\s*=/;
9177
+ const filtered = lines.filter((l) => !diaryLineRe.test(l));
9178
+ const strippedExisting = filtered.length !== lines.length;
9179
+ if (mode === "reuse") {
9180
+ if (!sourceDiaryId) return {
9181
+ mode,
9182
+ diaryId: null,
9183
+ modified: false
9184
+ };
9185
+ if (!DIARY_ID_RE.test(sourceDiaryId)) throw new Error(`invalid sourceDiaryId: ${JSON.stringify(sourceDiaryId)} — expected UUID`);
9186
+ const diaryLine = `MOLTNET_DIARY_ID='${sourceDiaryId}'`;
9187
+ const gitCfgIdx = filtered.findIndex((l) => /^\s*GIT_CONFIG_GLOBAL\s*=/.test(l));
9188
+ if (gitCfgIdx >= 0) filtered.splice(gitCfgIdx + 1, 0, diaryLine);
9189
+ else filtered.unshift(diaryLine);
9190
+ await writeFile(envPath, filtered.join("\n"), { mode: 384 });
9191
+ return {
9192
+ mode,
9193
+ diaryId: sourceDiaryId,
9194
+ modified: true
9195
+ };
9196
+ }
9197
+ if (strippedExisting) await writeFile(envPath, filtered.join("\n"), { mode: 384 });
9198
+ return {
9199
+ mode,
9200
+ diaryId: null,
9201
+ modified: strippedExisting
9202
+ };
9203
+ }
9204
+ //#endregion
9205
+ //#region src/phases/portRewrite.ts
9206
+ /**
9207
+ * Rewrite absolute paths in the ported `moltnet.json` so they point to
9208
+ * target locations, then regenerate the gitconfig and env file.
9209
+ *
9210
+ * Assumes `portCopy` already placed files at:
9211
+ * - `<targetDir>/moltnet.json`
9212
+ * - `<targetDir>/<appSlug>.pem`
9213
+ * - `<targetDir>/ssh/<basename(ssh.private_key_path)>`
9214
+ * - `<targetDir>/ssh/<basename(ssh.public_key_path)>`
9215
+ *
9216
+ * The ported config still has the *source* absolute paths — this phase
9217
+ * rewrites them to the target absolute paths, then writes the gitconfig
9218
+ * (which needs the new ssh key path) and the env file (which needs the
9219
+ * new PEM path).
9220
+ */
9221
+ async function runPortRewritePhase(opts) {
9222
+ const { targetDir, agentName, config } = opts;
9223
+ if (!config.ssh || !config.git || !config.github) throw new Error("config missing ssh/git/github sections — run portValidate first");
9224
+ const newSshPriv = join(targetDir, "ssh", basename(config.ssh.private_key_path));
9225
+ const newSshPub = join(targetDir, "ssh", basename(config.ssh.public_key_path));
9226
+ const newPem = join(targetDir, basename(config.github.private_key_path));
9227
+ const newGitConfig = join(targetDir, "gitconfig");
9228
+ await updateConfigSection("ssh", {
9229
+ private_key_path: newSshPriv,
9230
+ public_key_path: newSshPub
9231
+ }, targetDir);
9232
+ await updateConfigSection("github", {
9233
+ app_id: config.github.app_id,
9234
+ app_slug: config.github.app_slug,
9235
+ installation_id: config.github.installation_id,
9236
+ private_key_path: newPem,
9237
+ ...config.github.org ? { org: config.github.org } : {}
9238
+ }, targetDir);
9239
+ await updateConfigSection("git", {
9240
+ name: config.git.name,
9241
+ email: config.git.email,
9242
+ signing: config.git.signing,
9243
+ config_path: newGitConfig
9244
+ }, targetDir);
9245
+ const rewrittenFields = [
9246
+ "ssh.private_key_path",
9247
+ "ssh.public_key_path",
9248
+ "github.private_key_path",
9249
+ "git.config_path"
9250
+ ];
9251
+ await writeGitConfig({
9252
+ configDir: targetDir,
9253
+ name: config.git.name,
9254
+ email: config.git.email,
9255
+ sshKeyPath: newSshPriv
9256
+ });
9257
+ await writeEnvFile({
9258
+ envDir: targetDir,
9259
+ agentName,
9260
+ prefix: toEnvPrefix(agentName),
9261
+ clientId: config.oauth2.client_id,
9262
+ clientSecret: config.oauth2.client_secret,
9263
+ appId: config.github.app_id,
9264
+ pemPath: newPem,
9265
+ installationId: config.github.installation_id
9266
+ });
9267
+ return {
9268
+ configPath: join(targetDir, "moltnet.json"),
9269
+ rewrittenFields,
9270
+ gitConfigPath: newGitConfig,
9271
+ envDir: targetDir
9272
+ };
9273
+ }
9274
+ //#endregion
9275
+ //#region src/phases/portVerifyInstallation.ts
9276
+ /**
9277
+ * Warning-only check: can the ported GitHub App installation reach the
9278
+ * repo the port command is running against?
9279
+ *
9280
+ * Mints an installation token via github-agent, then calls
9281
+ * GET /installation/repositories. Never blocks — returns a warning
9282
+ * object the TUI renders. Any failure (bad token, network, missing
9283
+ * currentRepo) is downgraded to a warning.
9284
+ */
9285
+ async function runPortVerifyInstallationPhase(opts) {
9286
+ const { config, currentRepo, apiBaseUrl = "https://api.github.com" } = opts;
9287
+ if (!currentRepo) return {
9288
+ status: "warning",
9289
+ message: "unable to determine current repo (git remote missing) — skipping installation scope check"
9290
+ };
9291
+ if (!config.github?.app_id || !config.github?.installation_id || !config.github?.private_key_path) return {
9292
+ status: "warning",
9293
+ message: "github.app_id / installation_id / private_key_path missing",
9294
+ currentRepo
9295
+ };
9296
+ let token;
9297
+ try {
9298
+ token = (await getInstallationToken({
9299
+ appId: config.github.app_id,
9300
+ privateKeyPath: config.github.private_key_path,
9301
+ installationId: config.github.installation_id
9302
+ })).token;
9303
+ } catch (err) {
9304
+ return {
9305
+ status: "warning",
9306
+ message: `could not mint installation token: ${err.message}`,
9307
+ currentRepo
9308
+ };
9309
+ }
9310
+ const accessible = [];
9311
+ let nextUrl = `${apiBaseUrl}/installation/repositories?per_page=100`;
9312
+ let pageCount = 0;
9313
+ const MAX_PAGES = 20;
9314
+ while (nextUrl && pageCount < MAX_PAGES) {
9315
+ pageCount++;
9316
+ let res;
9317
+ try {
9318
+ res = await fetch(nextUrl, { headers: {
9319
+ Accept: "application/vnd.github+json",
9320
+ "X-GitHub-Api-Version": "2022-11-28",
9321
+ Authorization: `Bearer ${token}`
9322
+ } });
9323
+ } catch (err) {
9324
+ return {
9325
+ status: "warning",
9326
+ message: `installation check network error: ${err.message}`,
9327
+ currentRepo
9328
+ };
9329
+ }
9330
+ if (!res.ok) return {
9331
+ status: "warning",
9332
+ message: `installation check failed (${res.status})`,
9333
+ currentRepo
9334
+ };
9335
+ const data = await res.json();
9336
+ if (data.repository_selection === "all") return {
9337
+ status: "ok",
9338
+ message: "installation has access to all repos on the account",
9339
+ currentRepo,
9340
+ repositorySelection: "all"
9341
+ };
9342
+ for (const r of data.repositories) accessible.push(r.full_name);
9343
+ if (accessible.includes(currentRepo)) break;
9344
+ nextUrl = parseNextLink(res.headers.get("link"));
9345
+ }
9346
+ if (accessible.includes(currentRepo)) return {
9347
+ status: "ok",
9348
+ message: `installation has access to ${currentRepo}`,
9349
+ currentRepo,
9350
+ repositorySelection: "selected",
9351
+ accessibleRepos: accessible
9352
+ };
9353
+ const truncated = pageCount >= MAX_PAGES && nextUrl !== null;
9354
+ const truncatedNote = truncated ? " (scan truncated after " + MAX_PAGES + " pages — result may be stale)" : "";
9355
+ return {
9356
+ status: "repo-not-in-scope",
9357
+ message: `installation is scoped to ${accessible.length}${truncated ? "+" : ""} repo(s) but does not include ${currentRepo}. Add the repo at https://github.com/settings/installations/${config.github.installation_id}` + truncatedNote,
9358
+ currentRepo,
9359
+ repositorySelection: "selected",
9360
+ accessibleRepos: accessible
9361
+ };
9362
+ }
9363
+ /**
9364
+ * Parse the `Link` header for a `rel="next"` URL. Returns null if absent.
9365
+ * GitHub's Link header format:
9366
+ * <https://api.github.com/...?page=2>; rel="next", <...>; rel="last"
9367
+ */
9368
+ function parseNextLink(header) {
9369
+ if (!header) return null;
9370
+ for (const part of header.split(",")) {
9371
+ const match = part.match(/<([^>]+)>\s*;\s*rel="next"/);
9372
+ if (match) return match[1];
9373
+ }
9374
+ return null;
9375
+ }
9376
+ //#endregion
9377
+ //#region src/PortApp.tsx
9378
+ /** Read `owner/repo` from `git remote get-url origin`. Returns null on any failure. */
9379
+ function detectCurrentRepo(repoDir) {
9380
+ try {
9381
+ const match = execSync("git remote get-url origin", {
9382
+ cwd: repoDir,
9383
+ encoding: "utf-8",
9384
+ stdio: [
9385
+ "ignore",
9386
+ "pipe",
9387
+ "ignore"
9388
+ ]
9389
+ }).trim().match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/);
9390
+ return match ? match[1] : null;
9391
+ } catch {
9392
+ return null;
9393
+ }
9394
+ }
9395
+ function PortApp({ name, agents, sourceDir, targetRepoDir, diaryMode, apiUrl }) {
9396
+ const { exit } = useApp();
9397
+ const [phase, setPhase] = useState("validating");
9398
+ const [error, setError] = useState();
9399
+ const [summary, setSummary] = useState(null);
9400
+ useEffect(() => {
9401
+ (async () => {
9402
+ try {
9403
+ const targetDir = join(targetRepoDir, ".moltnet", name);
9404
+ const filesWritten = [];
9405
+ const warnings = [];
9406
+ setPhase("validating");
9407
+ const { config, issues, canProceed } = await runPortValidatePhase({ sourceDir });
9408
+ if (!canProceed) throw new Error("source .moltnet is not portable: " + issues.map((i) => `${i.field} (${i.problem})`).join(", "));
9409
+ const existing = await readConfig(targetDir);
9410
+ if (existing?.identity_id && existing.identity_id !== config.identity_id) throw new Error(`target ${targetDir} already has a different identity_id (${existing.identity_id}); refusing to overwrite`);
9411
+ setPhase("copying");
9412
+ const copyResult = await runPortCopyPhase({
9413
+ sourceDir,
9414
+ targetDir,
9415
+ config
9416
+ });
9417
+ filesWritten.push(...copyResult.copied);
9418
+ warnings.push(...copyResult.warnings);
9419
+ setPhase("rewriting");
9420
+ const rewriteResult = await runPortRewritePhase({
9421
+ targetDir,
9422
+ agentName: name,
9423
+ config
9424
+ });
9425
+ filesWritten.push(rewriteResult.gitConfigPath);
9426
+ filesWritten.push(join(targetDir, "env"));
9427
+ setPhase("diary");
9428
+ const diaryResult = await runPortDiaryPhase({
9429
+ targetDir,
9430
+ mode: diaryMode,
9431
+ sourceDiaryId: await readSourceDiaryId(sourceDir)
9432
+ });
9433
+ setPhase("agent_setup");
9434
+ const adapterOpts = {
9435
+ repoDir: targetRepoDir,
9436
+ agentName: name,
9437
+ prefix: toEnvPrefix(name),
9438
+ mcpUrl: config.endpoints?.mcp ?? apiUrl.replace("://api.", "://mcp.") + "/mcp",
9439
+ clientId: config.oauth2.client_id,
9440
+ clientSecret: config.oauth2.client_secret,
9441
+ appSlug: config.github?.app_slug ?? "",
9442
+ appId: config.github?.app_id ?? "",
9443
+ pemPath: join(targetDir, basename(config.github?.private_key_path ?? "")),
9444
+ installationId: config.github?.installation_id ?? ""
9445
+ };
9446
+ for (const agentType of agents) {
9447
+ const adapter = adapters[agentType];
9448
+ await adapter.writeMcpConfig(adapterOpts);
9449
+ filesWritten.push(`${agentType}: MCP config`);
9450
+ await adapter.writeSkills(targetRepoDir);
9451
+ filesWritten.push(`${agentType}: skills`);
9452
+ await adapter.writeSettings(adapterOpts);
9453
+ filesWritten.push(`${agentType}: settings`);
9454
+ await adapter.writeRules(adapterOpts);
9455
+ filesWritten.push(`${agentType}: gh token rule`);
9456
+ }
9457
+ setPhase("verifying");
9458
+ const verifyResult = await runPortVerifyInstallationPhase({
9459
+ config: await readConfig(targetDir) ?? config,
9460
+ currentRepo: detectCurrentRepo(targetRepoDir) ?? void 0
9461
+ });
9462
+ if (verifyResult.status !== "ok") warnings.push(verifyResult.message);
9463
+ setSummary({
9464
+ agentName: name,
9465
+ filesWritten,
9466
+ warnings,
9467
+ validationIssues: issues,
9468
+ diaryMode,
9469
+ diaryId: diaryResult.diaryId,
9470
+ installMessage: verifyResult.message,
9471
+ installStatus: verifyResult.status
9472
+ });
9473
+ setPhase("done");
9474
+ setTimeout(() => exit(), 3e3);
9475
+ } catch (err) {
9476
+ setError(toErrorMessage(err));
9477
+ setPhase("error");
9478
+ setTimeout(() => exit(/* @__PURE__ */ new Error("Port failed")), 3e3);
9479
+ }
9480
+ })();
9481
+ }, []);
9482
+ if (phase === "error") return /* @__PURE__ */ jsxs(Box, {
9483
+ flexDirection: "column",
9484
+ paddingY: 1,
9485
+ children: [/* @__PURE__ */ jsx(CliHero, {}), /* @__PURE__ */ jsx(Box, {
9486
+ borderStyle: "round",
9487
+ borderColor: cliTheme.color.error,
9488
+ paddingX: 2,
9489
+ paddingY: 1,
9490
+ children: /* @__PURE__ */ jsx(Text, {
9491
+ color: cliTheme.color.error,
9492
+ bold: true,
9493
+ children: "* Port failed: " + (error ?? "unknown error")
9494
+ })
9495
+ })]
9496
+ });
9497
+ if (phase !== "done") {
9498
+ const labels = {
9499
+ validating: `Validating source .moltnet/${name}...`,
9500
+ copying: `Copying private material...`,
9501
+ rewriting: `Rewriting paths in moltnet.json...`,
9502
+ diary: `Configuring diary (${diaryMode})...`,
9503
+ agent_setup: `Installing agent files for ${agents.join(", ")}...`,
9504
+ verifying: `Verifying GitHub App installation scope...`
9505
+ };
9506
+ return /* @__PURE__ */ jsxs(Box, {
9507
+ flexDirection: "column",
9508
+ paddingY: 1,
9509
+ children: [/* @__PURE__ */ jsx(CliHero, {}), /* @__PURE__ */ jsx(CliSpinner, { label: labels[phase] })]
9510
+ });
9511
+ }
9512
+ return /* @__PURE__ */ jsxs(Box, {
9513
+ flexDirection: "column",
9514
+ paddingY: 1,
9515
+ children: [
9516
+ /* @__PURE__ */ jsx(CliHero, {}),
9517
+ /* @__PURE__ */ jsxs(Box, {
9518
+ flexDirection: "column",
9519
+ marginBottom: 1,
9520
+ children: [
9521
+ /* @__PURE__ */ jsx(Text, {
9522
+ color: cliTheme.color.success,
9523
+ bold: true,
9524
+ children: `Ported ${name} to ${targetRepoDir}`
9525
+ }),
9526
+ /* @__PURE__ */ jsx(Text, {
9527
+ color: cliTheme.color.muted,
9528
+ children: ` diary: ${summary?.diaryMode}${summary?.diaryId ? ` (${summary.diaryId})` : ""}`
9529
+ }),
9530
+ /* @__PURE__ */ jsx(Text, {
9531
+ color: cliTheme.color.muted,
9532
+ children: ` installation: ${summary?.installStatus}`
9533
+ }),
9534
+ summary?.filesWritten.map((f, i) => /* @__PURE__ */ jsx(Text, {
9535
+ color: cliTheme.color.muted,
9536
+ children: " * " + f
9537
+ }, i))
9538
+ ]
9539
+ }),
9540
+ summary && summary.warnings.length > 0 && /* @__PURE__ */ jsxs(Box, {
9541
+ borderStyle: "round",
9542
+ borderColor: cliTheme.color.warning,
9543
+ paddingX: 2,
9544
+ paddingY: 0,
9545
+ flexDirection: "column",
9546
+ children: [/* @__PURE__ */ jsx(Text, {
9547
+ color: cliTheme.color.warning,
9548
+ bold: true,
9549
+ children: "Warnings:"
9550
+ }), summary.warnings.map((w, i) => /* @__PURE__ */ jsx(Text, {
9551
+ color: cliTheme.color.warning,
9552
+ children: " ! " + w
9553
+ }, i))]
9554
+ })
9555
+ ]
9556
+ });
9557
+ }
9558
+ //#endregion
8710
9559
  //#region src/SetupApp.tsx
8711
9560
  function SetupApp({ name, agents: agentsProp, apiUrl, dir }) {
8712
9561
  const { exit } = useApp();
@@ -8732,6 +9581,7 @@ function SetupApp({ name, agents: agentsProp, apiUrl, dir }) {
8732
9581
  clientId: config.oauth2.client_id,
8733
9582
  clientSecret: config.oauth2.client_secret,
8734
9583
  appSlug: config.github?.app_slug ?? config.github?.app_id ?? "",
9584
+ appId: config.github?.app_id ?? "",
8735
9585
  pemPath: config.github?.private_key_path ?? "",
8736
9586
  installationId: config.github?.installation_id ?? ""
8737
9587
  };
@@ -8835,7 +9685,13 @@ var { values, positionals } = parseArgs({
8835
9685
  multiple: true
8836
9686
  },
8837
9687
  "api-url": { type: "string" },
8838
- dir: { type: "string" }
9688
+ dir: { type: "string" },
9689
+ org: {
9690
+ type: "string",
9691
+ short: "o"
9692
+ },
9693
+ from: { type: "string" },
9694
+ diary: { type: "string" }
8839
9695
  }
8840
9696
  });
8841
9697
  var subcommand = positionals[0] ?? "init";
@@ -8843,6 +9699,13 @@ var name = values["name"];
8843
9699
  var agentFlags = values["agent"] ?? [];
8844
9700
  var apiUrl = values["api-url"] ?? process.env.MOLTNET_API_URL ?? "https://api.themolt.net";
8845
9701
  var dir = values["dir"] ?? process.cwd();
9702
+ var org = values["org"];
9703
+ var fromDir = values["from"];
9704
+ var diaryModeArg = values["diary"];
9705
+ if (diaryModeArg !== void 0 && subcommand !== "port") {
9706
+ process.stderr.write(`Error: --diary is only valid for \`legreffier port\` (got subcommand "${subcommand}")\n`);
9707
+ process.exit(1);
9708
+ }
8846
9709
  if (subcommand === "github" && positionals[1] === "token") try {
8847
9710
  printGitHubToken(resolveAgentName(name, process.env.GIT_CONFIG_GLOBAL), dir);
8848
9711
  process.exit(0);
@@ -8851,7 +9714,7 @@ if (subcommand === "github" && positionals[1] === "token") try {
8851
9714
  process.exit(1);
8852
9715
  }
8853
9716
  if (!name) {
8854
- const usage = subcommand === "setup" ? "Usage: legreffier setup --name <agent-name> [--agent claude] [--agent codex] [--dir <path>]" : "Usage: legreffier [init] --name <agent-name> [--agent claude] [--agent codex] [--api-url <url>] [--dir <path>]";
9717
+ const usage = subcommand === "setup" ? "Usage: legreffier setup --name <agent-name> [--agent claude] [--agent codex] [--dir <path>]" : subcommand === "port" ? "Usage: legreffier port --name <agent-name> --from <path/to/source/.moltnet/<agent>> [--agent claude] [--agent codex] [--dir <target-repo>] [--diary new|reuse|skip]" : "Usage: legreffier [init] --name <agent-name> [--agent claude] [--agent codex] [--api-url <url>] [--dir <path>] [--org <github-org>]";
8855
9718
  process.stderr.write(usage + "\n");
8856
9719
  process.exit(1);
8857
9720
  }
@@ -8874,10 +9737,51 @@ else if (subcommand === "init") render(/* @__PURE__ */ jsx(InitApp, {
8874
9737
  name,
8875
9738
  agents: agents.length > 0 ? agents : void 0,
8876
9739
  apiUrl,
8877
- dir
9740
+ dir,
9741
+ org
8878
9742
  }));
8879
- else {
8880
- process.stderr.write(`Unknown subcommand: ${subcommand}. Use "init" or "setup".\n`);
9743
+ else if (subcommand === "port") {
9744
+ if (!fromDir) {
9745
+ process.stderr.write("Error: legreffier port requires --from <path/to/source/.moltnet/<agent>>\n");
9746
+ process.exit(1);
9747
+ }
9748
+ const resolvedDiaryMode = diaryModeArg ?? "new";
9749
+ if (![
9750
+ "new",
9751
+ "reuse",
9752
+ "skip"
9753
+ ].includes(resolvedDiaryMode)) {
9754
+ process.stderr.write(`Error: --diary must be one of: new, reuse, skip (got "${resolvedDiaryMode}")\n`);
9755
+ process.exit(1);
9756
+ }
9757
+ try {
9758
+ if (!statSync(dir).isDirectory()) {
9759
+ process.stderr.write(`Error: --dir "${dir}" is not a directory\n`);
9760
+ process.exit(1);
9761
+ }
9762
+ } catch {
9763
+ process.stderr.write(`Error: --dir "${dir}" does not exist\n`);
9764
+ process.exit(1);
9765
+ }
9766
+ try {
9767
+ if (!statSync(fromDir).isDirectory()) {
9768
+ process.stderr.write(`Error: --from "${fromDir}" is not a directory\n`);
9769
+ process.exit(1);
9770
+ }
9771
+ } catch {
9772
+ process.stderr.write(`Error: --from "${fromDir}" does not exist\n`);
9773
+ process.exit(1);
9774
+ }
9775
+ render(/* @__PURE__ */ jsx(PortApp, {
9776
+ name,
9777
+ agents: agents.length > 0 ? agents : ["claude"],
9778
+ sourceDir: fromDir,
9779
+ targetRepoDir: dir,
9780
+ diaryMode: resolvedDiaryMode,
9781
+ apiUrl
9782
+ }));
9783
+ } else {
9784
+ process.stderr.write(`Unknown subcommand: ${subcommand}. Use "init", "setup", or "port".\n`);
8881
9785
  process.exit(1);
8882
9786
  }
8883
9787
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/legreffier",
3
- "version": "0.28.1",
3
+ "version": "0.29.0",
4
4
  "description": "LeGreffier — one-command accountable AI agent setup",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  "ink": "^6.8.0",
25
25
  "open": "^10.1.2",
26
26
  "react": "^19.0.0",
27
- "smol-toml": "^1.6.0"
27
+ "smol-toml": "^1.6.1"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/figlet": "^1.7.0",
@@ -33,10 +33,11 @@
33
33
  "typescript": "^5.3.3",
34
34
  "vite": "^8.0.0",
35
35
  "vitest": "^3.0.0",
36
- "@moltnet/crypto-service": "0.1.0",
37
- "@themoltnet/design-system": "0.3.2",
38
36
  "@moltnet/api-client": "0.1.0",
39
- "@themoltnet/sdk": "0.86.1"
37
+ "@themoltnet/design-system": "0.3.2",
38
+ "@moltnet/crypto-service": "0.1.0",
39
+ "@themoltnet/github-agent": "0.23.0",
40
+ "@themoltnet/sdk": "0.88.0"
40
41
  },
41
42
  "scripts": {
42
43
  "dev": "vite build --watch",