@gh-symphony/cli 0.0.1 → 0.0.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.
Files changed (59) hide show
  1. package/dist/ansi.d.ts +15 -0
  2. package/dist/ansi.js +53 -0
  3. package/dist/commands/config-cmd.js +11 -27
  4. package/dist/commands/help.js +14 -6
  5. package/dist/commands/init.d.ts +29 -7
  6. package/dist/commands/init.js +292 -287
  7. package/dist/commands/logs.js +4 -4
  8. package/dist/commands/project.js +34 -34
  9. package/dist/commands/recover.js +14 -14
  10. package/dist/commands/repo.js +13 -13
  11. package/dist/commands/run.js +16 -16
  12. package/dist/commands/start.js +61 -37
  13. package/dist/commands/status.js +60 -63
  14. package/dist/commands/tenant.d.ts +3 -0
  15. package/dist/commands/tenant.js +402 -0
  16. package/dist/config.d.ts +20 -19
  17. package/dist/config.js +17 -17
  18. package/dist/context/context-types.d.ts +36 -0
  19. package/dist/context/context-types.js +1 -0
  20. package/dist/context/generate-context-yaml.d.ts +15 -0
  21. package/dist/context/generate-context-yaml.js +129 -0
  22. package/dist/dashboard/renderer.d.ts +9 -0
  23. package/dist/dashboard/renderer.js +220 -0
  24. package/dist/detection/environment-detector.d.ts +11 -0
  25. package/dist/detection/environment-detector.js +140 -0
  26. package/dist/github/client.d.ts +11 -0
  27. package/dist/github/client.js +59 -11
  28. package/dist/github/gh-auth.d.ts +34 -0
  29. package/dist/github/gh-auth.js +110 -0
  30. package/dist/index.js +1 -0
  31. package/dist/mapping/smart-defaults.d.ts +9 -25
  32. package/dist/mapping/smart-defaults.js +52 -125
  33. package/dist/orchestrator-runtime.d.ts +4 -4
  34. package/dist/orchestrator-runtime.js +27 -12
  35. package/dist/skills/skill-writer.d.ts +14 -0
  36. package/dist/skills/skill-writer.js +62 -0
  37. package/dist/skills/templates/commit.d.ts +2 -0
  38. package/dist/skills/templates/commit.js +45 -0
  39. package/dist/skills/templates/document.d.ts +7 -0
  40. package/dist/skills/templates/document.js +16 -0
  41. package/dist/skills/templates/gh-project.d.ts +2 -0
  42. package/dist/skills/templates/gh-project.js +88 -0
  43. package/dist/skills/templates/gh-symphony.d.ts +2 -0
  44. package/dist/skills/templates/gh-symphony.js +125 -0
  45. package/dist/skills/templates/index.d.ts +8 -0
  46. package/dist/skills/templates/index.js +28 -0
  47. package/dist/skills/templates/land.d.ts +2 -0
  48. package/dist/skills/templates/land.js +59 -0
  49. package/dist/skills/templates/pull.d.ts +2 -0
  50. package/dist/skills/templates/pull.js +41 -0
  51. package/dist/skills/templates/push.d.ts +2 -0
  52. package/dist/skills/templates/push.js +36 -0
  53. package/dist/skills/types.d.ts +23 -0
  54. package/dist/skills/types.js +1 -0
  55. package/dist/workflow/generate-reference-workflow.d.ts +9 -0
  56. package/dist/workflow/generate-reference-workflow.js +261 -0
  57. package/dist/workflow/generate-workflow-md.d.ts +12 -0
  58. package/dist/workflow/generate-workflow-md.js +134 -0
  59. package/package.json +5 -4
@@ -1,32 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { resolveRuntimeRoot, resolveWorkspaceConfig, syncWorkspaceToRuntime, } from "../orchestrator-runtime.js";
4
- // ANSI color helpers
5
- function bold(s) {
6
- return `\x1b[1m${s}\x1b[0m`;
7
- }
8
- function dim(s) {
9
- return `\x1b[2m${s}\x1b[0m`;
10
- }
11
- function green(s) {
12
- return `\x1b[32m${s}\x1b[0m`;
13
- }
14
- function red(s) {
15
- return `\x1b[31m${s}\x1b[0m`;
16
- }
17
- function yellow(s) {
18
- return `\x1b[33m${s}\x1b[0m`;
19
- }
20
- function cyan(s) {
21
- return `\x1b[36m${s}\x1b[0m`;
22
- }
23
- function white(s) {
24
- return `\x1b[37m${s}\x1b[0m`;
25
- }
26
- function stripAnsi(s) {
27
- // eslint-disable-next-line no-control-regex
28
- return s.replace(/\x1b\[[0-9;]*m/g, "");
29
- }
3
+ import { resolveRuntimeRoot, resolveTenantConfig, syncTenantToRuntime, } from "../orchestrator-runtime.js";
4
+ import { bold, dim, green, red, yellow, cyan, stripAnsi } from "../ansi.js";
5
+ import { clearScreen, showCursor, hideCursor } from "../ansi.js";
6
+ import { renderDashboard } from "../dashboard/renderer.js";
30
7
  function healthIcon(health) {
31
8
  switch (health) {
32
9
  case "idle":
@@ -54,7 +31,7 @@ function truncate(s, len) {
54
31
  return s;
55
32
  return s.slice(0, len - 3) + "...";
56
33
  }
57
- function renderDashboard(snapshot, noColor) {
34
+ function renderLegacyStatus(snapshot, noColor) {
58
35
  const apply = noColor ? (s) => stripAnsi(s) : (s) => s;
59
36
  const lines = [];
60
37
  // Header
@@ -83,16 +60,7 @@ function renderDashboard(snapshot, noColor) {
83
60
  lines.push(" Active Runs:");
84
61
  for (const run of snapshot.activeRuns) {
85
62
  const runIdDisplay = truncate(run.runId, 12);
86
- const phaseColor = run.phase === "planning"
87
- ? cyan
88
- : run.phase === "human-review"
89
- ? yellow
90
- : run.phase === "implementation"
91
- ? cyan
92
- : run.phase === "awaiting-merge"
93
- ? yellow
94
- : white;
95
- const phaseStr = apply(phaseColor(run.phase));
63
+ const stateStr = apply(cyan(run.issueState));
96
64
  const statusColor = run.status === "running"
97
65
  ? green
98
66
  : run.status === "failed"
@@ -101,7 +69,7 @@ function renderDashboard(snapshot, noColor) {
101
69
  ? green
102
70
  : dim;
103
71
  const statusStr = apply(statusColor(run.status));
104
- lines.push(` ${runIdDisplay} ${run.issueIdentifier} ${phaseStr} ${statusStr}`);
72
+ lines.push(` ${runIdDisplay} ${run.issueIdentifier} ${stateStr} ${statusStr}`);
105
73
  }
106
74
  lines.push("");
107
75
  }
@@ -143,16 +111,16 @@ function parseStatusArgs(args) {
143
111
  if (arg === "--watch" || arg === "-w") {
144
112
  parsed.watch = true;
145
113
  }
146
- if (arg === "--workspace" || arg === "--workspace-id") {
147
- parsed.workspaceId = args[i + 1];
114
+ if (arg === "--tenant" || arg === "--tenant-id") {
115
+ parsed.tenantId = args[i + 1];
148
116
  i += 1;
149
117
  }
150
118
  }
151
119
  return parsed;
152
120
  }
153
- async function readStatusSnapshot(runtimeRoot, workspaceId) {
121
+ async function readStatusSnapshot(runtimeRoot, tenantId) {
154
122
  try {
155
- const statusPath = join(runtimeRoot, "orchestrator", "workspaces", workspaceId, "status.json");
123
+ const statusPath = join(runtimeRoot, "orchestrator", "tenants", tenantId, "status.json");
156
124
  const content = await readFile(statusPath, "utf-8");
157
125
  return JSON.parse(content);
158
126
  }
@@ -160,54 +128,83 @@ async function readStatusSnapshot(runtimeRoot, workspaceId) {
160
128
  return null;
161
129
  }
162
130
  }
131
+ async function readAllStatusSnapshots(runtimeRoot) {
132
+ try {
133
+ const tenantsDir = join(runtimeRoot, "orchestrator", "tenants");
134
+ const { readdir } = await import("node:fs/promises");
135
+ const entries = await readdir(tenantsDir, { withFileTypes: true });
136
+ const snapshots = [];
137
+ for (const entry of entries) {
138
+ if (!entry.isDirectory())
139
+ continue;
140
+ const statusPath = join(tenantsDir, entry.name, "status.json");
141
+ try {
142
+ const content = await readFile(statusPath, "utf-8");
143
+ snapshots.push(JSON.parse(content));
144
+ }
145
+ catch {
146
+ // skip missing/invalid files
147
+ }
148
+ }
149
+ return snapshots;
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ }
163
155
  const handler = async (args, options) => {
164
156
  const parsed = parseStatusArgs(args);
165
- const wsConfig = await resolveWorkspaceConfig(options.configDir, parsed.workspaceId);
166
- if (!wsConfig) {
167
- process.stderr.write("No workspace configured. Run 'gh-symphony init' first.\n");
157
+ const tenantConfig = await resolveTenantConfig(options.configDir, parsed.tenantId);
158
+ if (!tenantConfig) {
159
+ process.stderr.write("No tenant configured. Run 'gh-symphony init' first.\n");
168
160
  process.exitCode = 1;
169
161
  return;
170
162
  }
171
163
  const runtimeRoot = resolveRuntimeRoot(options.configDir);
172
- const workspaceId = wsConfig.workspaceId;
173
- await syncWorkspaceToRuntime(options.configDir, wsConfig);
164
+ const tenantId = tenantConfig.tenantId;
165
+ await syncTenantToRuntime(options.configDir, tenantConfig);
174
166
  if (parsed.watch) {
175
- // Watch mode: poll every 2 seconds
176
- const clear = () => process.stdout.write("\x1b[2J\x1b[H");
167
+ const isTTY = process.stdout.isTTY === true;
168
+ let terminalWidth = process.stdout.columns ?? 115;
177
169
  const run = async () => {
178
- clear();
179
- const snapshot = await readStatusSnapshot(runtimeRoot, workspaceId);
180
- if (snapshot) {
181
- if (options.json) {
182
- process.stdout.write(JSON.stringify(snapshot, null, 2) + "\n");
183
- }
184
- else {
185
- process.stdout.write(renderDashboard(snapshot, options.noColor) + "\n");
186
- }
170
+ const snapshots = await readAllStatusSnapshots(runtimeRoot);
171
+ if (options.json || !isTTY) {
172
+ process.stdout.write(JSON.stringify(snapshots, null, 2) + "\n");
187
173
  }
188
174
  else {
189
- process.stdout.write("Unable to read status snapshot.\n");
175
+ process.stdout.write(clearScreen() +
176
+ renderDashboard(snapshots, {
177
+ terminalWidth,
178
+ noColor: options.noColor,
179
+ }) +
180
+ "\n");
190
181
  }
191
182
  };
183
+ if (isTTY) {
184
+ process.stdout.write(hideCursor());
185
+ }
192
186
  await run();
193
187
  const interval = setInterval(() => void run(), 2000);
188
+ process.on("SIGWINCH", () => {
189
+ terminalWidth = process.stdout.columns ?? terminalWidth;
190
+ });
194
191
  const shutdown = () => {
195
192
  clearInterval(interval);
193
+ process.stdout.write(showCursor() + "\n");
196
194
  process.exit(0);
197
195
  };
198
196
  process.on("SIGINT", shutdown);
199
197
  process.on("SIGTERM", shutdown);
200
- // Keep alive
201
198
  await new Promise(() => { });
202
199
  }
203
200
  // Single status query
204
- const snapshot = await readStatusSnapshot(runtimeRoot, workspaceId);
201
+ const snapshot = await readStatusSnapshot(runtimeRoot, tenantId);
205
202
  if (snapshot) {
206
203
  if (options.json) {
207
204
  process.stdout.write(JSON.stringify(snapshot, null, 2) + "\n");
208
205
  }
209
206
  else {
210
- process.stdout.write(renderDashboard(snapshot, options.noColor) + "\n");
207
+ process.stdout.write(renderLegacyStatus(snapshot, options.noColor) + "\n");
211
208
  }
212
209
  }
213
210
  else {
@@ -0,0 +1,3 @@
1
+ import type { GlobalOptions } from "../index.js";
2
+ declare const handler: (args: string[], options: GlobalOptions) => Promise<void>;
3
+ export default handler;
@@ -0,0 +1,402 @@
1
+ import * as p from "@clack/prompts";
2
+ import { createClient, validateToken, checkRequiredScopes, listUserProjects, getProjectDetail, GitHubScopeError, } from "../github/client.js";
3
+ import { ensureGhAuth, getGhToken, GhAuthError } from "../github/gh-auth.js";
4
+ import { inferAllStateRoles, toWorkflowLifecycleConfig, validateStateMapping, } from "../mapping/smart-defaults.js";
5
+ import { loadGlobalConfig, saveGlobalConfig, loadTenantConfig, tenantConfigDir, } from "../config.js";
6
+ import { writeConfig, generateTenantId, abortIfCancelled } from "./init.js";
7
+ // ── Scope error display ───────────────────────────────────────────────────────
8
+ const KNOWN_REQUIRED_SCOPES = ["repo", "read:org", "project"];
9
+ function displayScopeError(error, retryCommand) {
10
+ const plural = error.requiredScopes.length === 1 ? "" : "s";
11
+ p.log.error(`Token is missing required scope${plural}: ${error.requiredScopes.join(", ")}`);
12
+ const currentSet = new Set(error.currentScopes.map((s) => s.toLowerCase()));
13
+ const scopesToAdd = KNOWN_REQUIRED_SCOPES.filter((s) => !currentSet.has(s));
14
+ const scopeArg = scopesToAdd.length > 0
15
+ ? scopesToAdd.join(",")
16
+ : error.requiredScopes.join(",");
17
+ p.note(`gh auth refresh --scopes ${scopeArg}\n\nThen re-run: ${retryCommand}`, "Fix missing scope");
18
+ }
19
+ function parseTenantAddFlags(args) {
20
+ const flags = { nonInteractive: false };
21
+ for (let i = 0; i < args.length; i += 1) {
22
+ const arg = args[i];
23
+ const next = args[i + 1];
24
+ switch (arg) {
25
+ case "--non-interactive":
26
+ flags.nonInteractive = true;
27
+ break;
28
+ case "--project":
29
+ flags.project = next;
30
+ i += 1;
31
+ break;
32
+ case "--runtime":
33
+ flags.runtime = next;
34
+ i += 1;
35
+ break;
36
+ }
37
+ }
38
+ return flags;
39
+ }
40
+ // ── Tenant command handler ────────────────────────────────────────────────────
41
+ const handler = async (args, options) => {
42
+ const [subcommand, ...rest] = args;
43
+ switch (subcommand) {
44
+ case "add":
45
+ await tenantAdd(rest, options);
46
+ return;
47
+ case "list":
48
+ await tenantList(options);
49
+ return;
50
+ case "remove":
51
+ await tenantRemove(rest, options);
52
+ return;
53
+ default:
54
+ process.stdout.write("Usage: gh-symphony tenant <add|list|remove>\n");
55
+ }
56
+ };
57
+ export default handler;
58
+ // ── tenant add ───────────────────────────────────────────────────────────────
59
+ async function tenantAdd(args, options) {
60
+ const flags = parseTenantAddFlags(args);
61
+ if (flags.nonInteractive) {
62
+ await tenantAddNonInteractive(flags, options);
63
+ return;
64
+ }
65
+ await tenantAddInteractive(options);
66
+ }
67
+ // ── Non-interactive tenant add ───────────────────────────────────────────────
68
+ async function tenantAddNonInteractive(flags, options) {
69
+ let token;
70
+ try {
71
+ token = getGhToken();
72
+ }
73
+ catch {
74
+ process.stderr.write("Error: GitHub token not found. Run 'gh auth login --scopes repo,read:org,project' or set GITHUB_GRAPHQL_TOKEN.\n");
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ const client = createClient(token);
79
+ // Validate token
80
+ let viewer;
81
+ try {
82
+ viewer = await validateToken(client);
83
+ }
84
+ catch {
85
+ process.stderr.write("Error: Invalid GitHub token.\n");
86
+ process.exitCode = 1;
87
+ return;
88
+ }
89
+ const scopeCheck = checkRequiredScopes(viewer.scopes);
90
+ if (!scopeCheck.valid) {
91
+ process.stderr.write(`Error: Missing required PAT scopes: ${scopeCheck.missing.join(", ")}\n`);
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+ // Find project
96
+ const projects = await listUserProjects(client);
97
+ let project;
98
+ if (flags.project) {
99
+ const match = projects.find((p) => p.id === flags.project || p.url === flags.project);
100
+ if (!match) {
101
+ process.stderr.write(`Error: Project not found: ${flags.project}\n`);
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+ project = await getProjectDetail(client, match.id);
106
+ }
107
+ else if (projects.length === 1) {
108
+ project = await getProjectDetail(client, projects[0].id);
109
+ }
110
+ else {
111
+ process.stderr.write("Error: --project is required when multiple projects exist.\n");
112
+ process.exitCode = 1;
113
+ return;
114
+ }
115
+ // Auto-map with smart defaults
116
+ const statusField = project.statusFields.find((f) => f.name.toLowerCase() === "status") ??
117
+ project.statusFields[0];
118
+ if (!statusField) {
119
+ process.stderr.write("Error: No status field found on the project.\n");
120
+ process.exitCode = 1;
121
+ return;
122
+ }
123
+ const columnNames = statusField.options.map((o) => o.name);
124
+ const inferred = inferAllStateRoles(columnNames);
125
+ const mappings = {};
126
+ for (const mapping of inferred) {
127
+ if (mapping.role) {
128
+ mappings[mapping.columnName] = { role: mapping.role };
129
+ }
130
+ }
131
+ const validation = validateStateMapping(mappings);
132
+ if (!validation.valid) {
133
+ process.stderr.write(`Error: Cannot auto-map columns. ${validation.errors.join("; ")}\nRun without --non-interactive for manual mapping.\n`);
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+ const runtime = flags.runtime ?? "codex";
138
+ const tenantId = generateTenantId(project.title, project.id);
139
+ await writeConfig(options.configDir, {
140
+ tenantId,
141
+ project,
142
+ repos: project.linkedRepositories,
143
+ statusField,
144
+ mappings,
145
+ runtime,
146
+ });
147
+ if (options.json) {
148
+ process.stdout.write(JSON.stringify({ tenantId, status: "created" }) + "\n");
149
+ }
150
+ else {
151
+ process.stdout.write(`Tenant created: ${tenantId}\n`);
152
+ process.stdout.write(`Run 'gh-symphony start' to begin orchestration.\n`);
153
+ }
154
+ }
155
+ // ── Interactive tenant add ───────────────────────────────────────────────────
156
+ async function tenantAddInteractive(options) {
157
+ p.intro("gh-symphony — Tenant Setup");
158
+ // Detect existing config
159
+ const existingConfig = await loadGlobalConfig(options.configDir);
160
+ if (existingConfig) {
161
+ const action = await abortIfCancelled(p.select({
162
+ message: "Existing configuration detected. What would you like to do?",
163
+ options: [
164
+ { value: "add", label: "Add a new tenant" },
165
+ { value: "overwrite", label: "Start fresh (overwrite)" },
166
+ ],
167
+ }));
168
+ if (action === "overwrite") {
169
+ // Continue with fresh setup — will overwrite config
170
+ }
171
+ // "add" continues to create a new tenant alongside existing ones
172
+ }
173
+ // ── Step 1: gh CLI authentication ─────────────────────────────────────────────
174
+ const s1 = p.spinner();
175
+ s1.start("Checking gh CLI authentication...");
176
+ let login;
177
+ let client;
178
+ try {
179
+ const { login: ghLogin, token } = ensureGhAuth();
180
+ login = ghLogin;
181
+ client = createClient(token);
182
+ s1.stop(`Authenticated as ${login}`);
183
+ }
184
+ catch (error) {
185
+ s1.stop("Authentication failed.");
186
+ if (error instanceof GhAuthError) {
187
+ if (error.code === "not_installed") {
188
+ p.log.error("gh CLI가 설치되어 있지 않습니다. https://cli.github.com 에서 설치하세요.");
189
+ }
190
+ else if (error.code === "not_authenticated") {
191
+ p.log.error("gh auth login --scopes repo,read:org,project 를 실행하세요.");
192
+ }
193
+ else if (error.code === "missing_scopes") {
194
+ p.log.error("gh auth refresh --scopes repo,read:org,project 를 실행하세요.");
195
+ }
196
+ else {
197
+ p.log.error(error.message);
198
+ }
199
+ }
200
+ else {
201
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
202
+ }
203
+ process.exitCode = 1;
204
+ return;
205
+ }
206
+ // ── Step 2: Project selection ───────────────────────────────────────────────
207
+ const s2 = p.spinner();
208
+ s2.start("Loading projects...");
209
+ let projects;
210
+ try {
211
+ projects = await listUserProjects(client);
212
+ s2.stop(`Found ${projects.length} project${projects.length === 1 ? "" : "s"}`);
213
+ }
214
+ catch (error) {
215
+ s2.stop("Failed to load projects.");
216
+ if (error instanceof GitHubScopeError) {
217
+ displayScopeError(error, "gh-symphony tenant add");
218
+ }
219
+ else {
220
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
221
+ }
222
+ process.exitCode = 1;
223
+ return;
224
+ }
225
+ if (projects.length === 0) {
226
+ p.log.error("No GitHub Projects found. Create a project at https://github.com/orgs/YOUR_ORG/projects and re-run.");
227
+ process.exitCode = 1;
228
+ return;
229
+ }
230
+ const selectedProjectId = await abortIfCancelled(p.select({
231
+ message: "Step 1/3 — Select a GitHub Project:",
232
+ options: projects.map((proj) => ({
233
+ value: proj.id,
234
+ label: `${proj.owner.login}/${proj.title}`,
235
+ hint: `${proj.openItemCount} items`,
236
+ })),
237
+ maxItems: 15,
238
+ }));
239
+ const s2d = p.spinner();
240
+ s2d.start("Loading project details...");
241
+ let projectDetail;
242
+ try {
243
+ projectDetail = await getProjectDetail(client, selectedProjectId);
244
+ s2d.stop(`Loaded: ${projectDetail.title}`);
245
+ }
246
+ catch (error) {
247
+ s2d.stop("Failed to load project details.");
248
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+ // ── Step 3: Repository selection ────────────────────────────────────────────
253
+ if (projectDetail.linkedRepositories.length === 0) {
254
+ p.log.warn("No linked repositories found in this project. Add issues from repositories to the project first.");
255
+ process.exitCode = 1;
256
+ return;
257
+ }
258
+ const selectedRepos = await abortIfCancelled(p.multiselect({
259
+ message: "Step 2/3 — Select repositories to orchestrate:",
260
+ options: projectDetail.linkedRepositories.map((repo) => ({
261
+ value: repo,
262
+ label: `${repo.owner}/${repo.name}`,
263
+ })),
264
+ required: true,
265
+ }));
266
+ // ── Step 4: Status column auto-detection ─────────────────────────────────────
267
+ const statusField = projectDetail.statusFields.find((f) => f.name.toLowerCase() === "status") ??
268
+ projectDetail.statusFields[0];
269
+ if (!statusField) {
270
+ p.log.error("No status field found on the project. The project needs a single-select 'Status' field.");
271
+ process.exitCode = 1;
272
+ return;
273
+ }
274
+ const columnNames = statusField.options.map((o) => o.name);
275
+ const inferred = inferAllStateRoles(columnNames);
276
+ const mappings = {};
277
+ for (const mapping of inferred) {
278
+ if (mapping.role) {
279
+ mappings[mapping.columnName] = { role: mapping.role };
280
+ }
281
+ }
282
+ const validation = validateStateMapping(mappings);
283
+ if (!validation.valid) {
284
+ p.log.error(`Cannot auto-map status columns: ${validation.errors.join("; ")}\nRun 'gh-symphony init' to manually configure WORKFLOW.md.`);
285
+ process.exitCode = 1;
286
+ return;
287
+ }
288
+ const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
289
+ p.log.info(`Auto-detected workflow: Active=[${lifecycleConfig.activeStates.join(", ")}] Terminal=[${lifecycleConfig.terminalStates.join(", ")}]`);
290
+ // ── Step 4: Runtime selection ────────────────────────────────────────────────
291
+ const runtime = await abortIfCancelled(p.select({
292
+ message: "Step 3/3 — Select AI runtime:",
293
+ options: [
294
+ { value: "codex", label: "OpenAI Codex", hint: "recommended" },
295
+ { value: "claude-code", label: "Claude Code" },
296
+ { value: "custom", label: "Custom command" },
297
+ ],
298
+ }));
299
+ let agentCommand;
300
+ if (runtime === "custom") {
301
+ agentCommand = await abortIfCancelled(p.text({
302
+ message: "Custom agent command:",
303
+ placeholder: "bash -lc my-agent",
304
+ }));
305
+ }
306
+ // ── Confirmation ─────────────────────────────────────────────────────────────
307
+ p.note([
308
+ `User: ${login}`,
309
+ `Project: ${projectDetail.title}`,
310
+ `Repos: ${selectedRepos.map((r) => `${r.owner}/${r.name}`).join(", ")}`,
311
+ `Runtime: ${runtime}`,
312
+ `Active: ${lifecycleConfig.activeStates.join(", ")}`,
313
+ `Terminal: ${lifecycleConfig.terminalStates.join(", ")}`,
314
+ ].join("\n"), "Configuration Summary");
315
+ const confirmed = await abortIfCancelled(p.confirm({ message: "Apply this configuration?" }));
316
+ if (!confirmed) {
317
+ p.cancel("Setup cancelled.");
318
+ process.exitCode = 130;
319
+ return;
320
+ }
321
+ // ── Write config files ────────────────────────────────────────────────────────
322
+ const tenantId = generateTenantId(projectDetail.title, projectDetail.id);
323
+ const s6 = p.spinner();
324
+ s6.start("Writing configuration...");
325
+ try {
326
+ await writeConfig(options.configDir, {
327
+ tenantId,
328
+ project: projectDetail,
329
+ repos: selectedRepos,
330
+ statusField: {
331
+ id: statusField.id,
332
+ name: statusField.name,
333
+ options: statusField.options,
334
+ },
335
+ mappings,
336
+ runtime,
337
+ agentCommand,
338
+ });
339
+ s6.stop("Configuration saved.");
340
+ }
341
+ catch (error) {
342
+ s6.stop("Failed to write configuration.");
343
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
344
+ process.exitCode = 1;
345
+ return;
346
+ }
347
+ p.log.info(`WORKFLOW.md generated at ${tenantId}/WORKFLOW.md — edit it to customize your team policy.`);
348
+ p.outro(`Tenant "${tenantId}" created!\n Run 'gh-symphony start' to begin orchestration.`);
349
+ }
350
+ // ── tenant list ───────────────────────────────────────────────────────────────
351
+ async function tenantList(options) {
352
+ const global = await loadGlobalConfig(options.configDir);
353
+ if (!global?.tenants?.length) {
354
+ process.stdout.write("No tenants configured.\n");
355
+ return;
356
+ }
357
+ process.stdout.write("Configured tenants:\n");
358
+ const configs = await Promise.all(global.tenants.map((id) => loadTenantConfig(options.configDir, id)));
359
+ for (let i = 0; i < global.tenants.length; i++) {
360
+ const tenantId = global.tenants[i];
361
+ const config = configs[i];
362
+ const active = global.activeTenant === tenantId ? " (active)" : "";
363
+ const slug = config?.slug ?? tenantId;
364
+ process.stdout.write(` ${slug}${active}\n`);
365
+ }
366
+ }
367
+ // ── tenant remove ─────────────────────────────────────────────────────────────
368
+ async function tenantRemove(args, options) {
369
+ const tenantId = args[0];
370
+ if (!tenantId) {
371
+ process.stderr.write("Usage: gh-symphony tenant remove <tenant-id>\n");
372
+ process.exitCode = 1;
373
+ return;
374
+ }
375
+ const global = await loadGlobalConfig(options.configDir);
376
+ if (!global) {
377
+ process.stderr.write("No configuration found.\n");
378
+ process.exitCode = 1;
379
+ return;
380
+ }
381
+ const updatedTenants = (global.tenants ?? []).filter((t) => t !== tenantId);
382
+ if (updatedTenants.length === global.tenants.length) {
383
+ process.stderr.write(`Tenant "${tenantId}" not found.\n`);
384
+ process.exitCode = 1;
385
+ return;
386
+ }
387
+ const updatedConfig = {
388
+ ...global,
389
+ tenants: updatedTenants,
390
+ activeTenant: global.activeTenant === tenantId ? null : global.activeTenant,
391
+ };
392
+ await saveGlobalConfig(options.configDir, updatedConfig);
393
+ const { rm } = await import("node:fs/promises");
394
+ const dir = tenantConfigDir(options.configDir, tenantId);
395
+ try {
396
+ await rm(dir, { recursive: true, force: true });
397
+ }
398
+ catch {
399
+ // Directory may not exist
400
+ }
401
+ process.stdout.write(`Tenant "${tenantId}" removed.\n`);
402
+ }
package/dist/config.d.ts CHANGED
@@ -1,36 +1,37 @@
1
- import type { OrchestratorWorkspaceConfig, WorkflowLifecycleConfig } from "@hojinzs/gh-symphony-core";
1
+ import type { OrchestratorTenantConfig, WorkflowLifecycleConfig } from "@gh-symphony/core";
2
2
  export declare const DEFAULT_CONFIG_DIR: string;
3
3
  export declare const CONFIG_FILE = "config.json";
4
4
  export declare const DAEMON_PID_FILE = "daemon.pid";
5
5
  export declare const LOGS_DIR = "logs";
6
6
  export type CliGlobalConfig = {
7
- activeWorkspace: string | null;
8
- token: string | null;
9
- workspaces: string[];
7
+ activeTenant: string | null;
8
+ tenants: string[];
10
9
  };
11
- export type CliWorkspaceConfig = OrchestratorWorkspaceConfig & {
12
- workflowMapping?: WorkflowMappingConfig;
10
+ export type CliTenantConfig = OrchestratorTenantConfig & {
11
+ workflowMapping?: WorkflowStateConfig;
13
12
  };
14
- export type WorkflowMappingConfig = {
13
+ export type StateRole = "active" | "wait" | "terminal";
14
+ export type StateMapping = {
15
+ role: StateRole;
16
+ goal?: string;
17
+ };
18
+ export type WorkflowStateConfig = {
15
19
  stateFieldName: string;
16
- columnRoles: Record<string, ColumnRole>;
17
- humanReviewMode: HumanReviewMode;
20
+ mappings: Record<string, StateMapping>;
18
21
  lifecycle: WorkflowLifecycleConfig;
19
22
  };
20
- export type ColumnRole = "trigger" | "working" | "human-review" | "done" | "ignored";
21
- export type HumanReviewMode = "plan-and-pr" | "plan-only" | "pr-only" | "none";
22
23
  export declare function resolveConfigDir(override?: string): string;
23
24
  export declare function configFilePath(configDir: string): string;
24
- export declare function workspaceConfigDir(configDir: string, workspaceId: string): string;
25
- export declare function workspaceConfigPath(configDir: string, workspaceId: string): string;
26
- export declare function workflowMappingPath(configDir: string, workspaceId: string): string;
25
+ export declare function tenantConfigDir(configDir: string, tenantId: string): string;
26
+ export declare function tenantConfigPath(configDir: string, tenantId: string): string;
27
+ export declare function workflowMappingPath(configDir: string, tenantId: string): string;
27
28
  export declare function daemonPidPath(configDir: string): string;
28
29
  export declare function logsDir(configDir: string): string;
29
30
  export declare function orchestratorLogPath(configDir: string): string;
30
31
  export declare function loadGlobalConfig(configDir: string): Promise<CliGlobalConfig | null>;
31
32
  export declare function saveGlobalConfig(configDir: string, config: CliGlobalConfig): Promise<void>;
32
- export declare function loadWorkspaceConfig(configDir: string, workspaceId: string): Promise<CliWorkspaceConfig | null>;
33
- export declare function saveWorkspaceConfig(configDir: string, workspaceId: string, config: CliWorkspaceConfig): Promise<void>;
34
- export declare function loadWorkflowMapping(configDir: string, workspaceId: string): Promise<WorkflowMappingConfig | null>;
35
- export declare function saveWorkflowMapping(configDir: string, workspaceId: string, mapping: WorkflowMappingConfig): Promise<void>;
36
- export declare function loadActiveWorkspaceConfig(configDir: string): Promise<CliWorkspaceConfig | null>;
33
+ export declare function loadTenantConfig(configDir: string, tenantId: string): Promise<CliTenantConfig | null>;
34
+ export declare function saveTenantConfig(configDir: string, tenantId: string, config: CliTenantConfig): Promise<void>;
35
+ export declare function loadWorkflowMapping(configDir: string, tenantId: string): Promise<WorkflowStateConfig | null>;
36
+ export declare function saveWorkflowMapping(configDir: string, tenantId: string, mapping: WorkflowStateConfig): Promise<void>;
37
+ export declare function loadActiveTenantConfig(configDir: string): Promise<CliTenantConfig | null>;