@gh-symphony/cli 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +1 -0
- package/dist/commands/start.js +3 -0
- package/dist/commands/status-refresh.d.ts +8 -0
- package/dist/commands/status-refresh.js +21 -0
- package/dist/commands/status.js +17 -2
- package/dist/commands/tenant.js +16 -5
- package/dist/config.d.ts +8 -1
- package/dist/dashboard/renderer.js +2 -2
- package/dist/github/gh-auth.js +2 -2
- package/dist/index.js +2 -1
- package/package.json +5 -5
package/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# @gh-symphony/cli
|
|
2
|
+
|
|
3
|
+
Interactive CLI for GitHub Symphony — a multi-tenant AI coding agent orchestration platform.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
The following tools must be installed before using the CLI:
|
|
8
|
+
|
|
9
|
+
- **[Node.js](https://nodejs.org/)** (v24+) with npm
|
|
10
|
+
- **[Git](https://git-scm.com/)**
|
|
11
|
+
- **[GitHub CLI (`gh`)](https://cli.github.com/)** — authenticated with required scopes:
|
|
12
|
+
```bash
|
|
13
|
+
gh auth login --scopes repo,read:org,project
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 1. Install Package
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g @gh-symphony/cli
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Verify the installation:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gh-symphony --version
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 2. Set Repository
|
|
29
|
+
|
|
30
|
+
Navigate to the repository you want to orchestrate, then run:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gh-symphony init
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The interactive wizard will:
|
|
37
|
+
|
|
38
|
+
1. Authenticate via `gh` CLI
|
|
39
|
+
2. Let you select a **GitHub Project** to bind
|
|
40
|
+
3. Map project status columns to workflow phases (active / wait / terminal)
|
|
41
|
+
4. Generate `WORKFLOW.md` and supporting files in the repository
|
|
42
|
+
|
|
43
|
+
### Customizing Agent Behavior
|
|
44
|
+
|
|
45
|
+
`gh-symphony init` generates skill files under `.codex/skills/` (or `.claude/skills/` for Claude Code). These skills define how the AI agent handles commits, pushes, pulls, and project status transitions.
|
|
46
|
+
|
|
47
|
+
You can further customize the agent's behavior by editing `WORKFLOW.md` — this is the policy layer that controls what the agent does at each workflow phase.
|
|
48
|
+
|
|
49
|
+
> Currently supported runtimes: **Codex**, **Claude Code**
|
|
50
|
+
|
|
51
|
+
## 3. Set Orchestrator Runner (Tenant)
|
|
52
|
+
|
|
53
|
+
On the machine where you want the orchestrator to run, register a tenant:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
gh-symphony tenant add
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The interactive wizard will:
|
|
60
|
+
|
|
61
|
+
1. Authenticate via `gh` CLI
|
|
62
|
+
2. Let you select a **GitHub Project**
|
|
63
|
+
3. Select repositories to orchestrate
|
|
64
|
+
4. Auto-detect workflow column mappings
|
|
65
|
+
5. Choose an AI runtime (Codex / Claude Code / custom)
|
|
66
|
+
6. Write tenant configuration to `~/.gh-symphony/`
|
|
67
|
+
|
|
68
|
+
### Tenant Management
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
gh-symphony tenant list # List all configured tenants
|
|
72
|
+
gh-symphony tenant remove <id> # Remove a tenant
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 4. Run the Orchestrator
|
|
76
|
+
|
|
77
|
+
### Foreground
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
gh-symphony start
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Background (daemon)
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
gh-symphony start --daemon # Start in background
|
|
87
|
+
gh-symphony stop # Stop the daemon
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Monitor
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
gh-symphony status # Show current status
|
|
94
|
+
gh-symphony status --watch # Live dashboard
|
|
95
|
+
gh-symphony logs # View event logs
|
|
96
|
+
gh-symphony logs --follow # Stream logs in real-time
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Dispatch a Single Issue
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
gh-symphony run org/repo#123
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Recover Stalled Runs
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
gh-symphony recover # Recover stalled runs
|
|
109
|
+
gh-symphony recover --dry-run # Preview what would be recovered
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Command Reference
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
Setup:
|
|
116
|
+
init Interactive repository setup wizard
|
|
117
|
+
config show Show current configuration
|
|
118
|
+
config set Set a configuration value
|
|
119
|
+
config edit Open config in $EDITOR
|
|
120
|
+
|
|
121
|
+
Orchestration:
|
|
122
|
+
start Start the orchestrator (foreground)
|
|
123
|
+
start --daemon Start the orchestrator (background)
|
|
124
|
+
stop Stop the background orchestrator
|
|
125
|
+
status Show orchestrator status
|
|
126
|
+
run <issue> Dispatch a single issue
|
|
127
|
+
recover Recover stalled runs
|
|
128
|
+
logs View orchestrator logs
|
|
129
|
+
|
|
130
|
+
Tenant Management:
|
|
131
|
+
tenant add Add a new tenant (interactive wizard)
|
|
132
|
+
tenant list List all configured tenants
|
|
133
|
+
tenant remove Remove a tenant
|
|
134
|
+
|
|
135
|
+
Global Options:
|
|
136
|
+
--config <dir> Config directory (default: ~/.gh-symphony)
|
|
137
|
+
--verbose Enable verbose output
|
|
138
|
+
--json Output in JSON format
|
|
139
|
+
--no-color Disable color output
|
|
140
|
+
--help, -h Show help
|
|
141
|
+
--version, -V Show version
|
|
142
|
+
```
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -43,6 +43,7 @@ type WriteConfigInput = {
|
|
|
43
43
|
pollIntervalMs?: number;
|
|
44
44
|
concurrency?: number;
|
|
45
45
|
maxAttempts?: number;
|
|
46
|
+
assignedOnly?: boolean;
|
|
46
47
|
};
|
|
47
48
|
export declare function writeConfig(configDir: string, input: WriteConfigInput): Promise<void>;
|
|
48
49
|
export declare function generateTenantId(projectTitle: string, uniqueKey: string): string;
|
package/dist/commands/init.js
CHANGED
package/dist/commands/start.js
CHANGED
|
@@ -151,6 +151,9 @@ const handler = async (args, options) => {
|
|
|
151
151
|
return snapshot ?? null;
|
|
152
152
|
},
|
|
153
153
|
},
|
|
154
|
+
onRefresh: async () => {
|
|
155
|
+
await service.runOnce({ tenantId });
|
|
156
|
+
},
|
|
154
157
|
});
|
|
155
158
|
logLine(green("\u25B2"), `Starting orchestrator for tenant: ${bold(tenantId)}`);
|
|
156
159
|
logLine(dim("\u00B7"), dim("Press Ctrl+C to stop"));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type RefreshRequestOptions = {
|
|
2
|
+
fetchImpl?: typeof fetch;
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
env?: NodeJS.ProcessEnv;
|
|
5
|
+
};
|
|
6
|
+
export declare function resolveOrchestratorStatusBaseUrl(env?: NodeJS.ProcessEnv): string;
|
|
7
|
+
export declare function requestOrchestratorRefresh(options?: RefreshRequestOptions): Promise<boolean>;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function resolveOrchestratorStatusBaseUrl(env = process.env) {
|
|
2
|
+
const host = env.ORCHESTRATOR_STATUS_HOST ?? "127.0.0.1";
|
|
3
|
+
const port = env.ORCHESTRATOR_STATUS_PORT ?? "4680";
|
|
4
|
+
const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
5
|
+
return `http://${urlHost}:${port}`;
|
|
6
|
+
}
|
|
7
|
+
export async function requestOrchestratorRefresh(options = {}) {
|
|
8
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
9
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
10
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetchImpl(`${resolveOrchestratorStatusBaseUrl(options.env)}/api/v1/refresh`, {
|
|
13
|
+
method: "POST",
|
|
14
|
+
signal,
|
|
15
|
+
});
|
|
16
|
+
return response.ok;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -4,6 +4,8 @@ import { resolveRuntimeRoot, resolveTenantConfig, syncTenantToRuntime, } from ".
|
|
|
4
4
|
import { bold, dim, green, red, yellow, cyan, stripAnsi } from "../ansi.js";
|
|
5
5
|
import { clearScreen, showCursor, hideCursor } from "../ansi.js";
|
|
6
6
|
import { renderDashboard } from "../dashboard/renderer.js";
|
|
7
|
+
import { requestOrchestratorRefresh } from "./status-refresh.js";
|
|
8
|
+
const WATCH_REFRESH_TIMEOUT_MS = 1_500;
|
|
7
9
|
function healthIcon(health) {
|
|
8
10
|
switch (health) {
|
|
9
11
|
case "idle":
|
|
@@ -166,7 +168,11 @@ const handler = async (args, options) => {
|
|
|
166
168
|
if (parsed.watch) {
|
|
167
169
|
const isTTY = process.stdout.isTTY === true;
|
|
168
170
|
let terminalWidth = process.stdout.columns ?? 115;
|
|
171
|
+
let runPromise = null;
|
|
169
172
|
const run = async () => {
|
|
173
|
+
await requestOrchestratorRefresh({
|
|
174
|
+
timeoutMs: WATCH_REFRESH_TIMEOUT_MS,
|
|
175
|
+
});
|
|
170
176
|
const snapshots = await readAllStatusSnapshots(runtimeRoot);
|
|
171
177
|
if (options.json || !isTTY) {
|
|
172
178
|
process.stdout.write(JSON.stringify(snapshots, null, 2) + "\n");
|
|
@@ -180,11 +186,20 @@ const handler = async (args, options) => {
|
|
|
180
186
|
"\n");
|
|
181
187
|
}
|
|
182
188
|
};
|
|
189
|
+
const tick = () => {
|
|
190
|
+
if (runPromise) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
runPromise = run().finally(() => {
|
|
194
|
+
runPromise = null;
|
|
195
|
+
});
|
|
196
|
+
};
|
|
183
197
|
if (isTTY) {
|
|
184
198
|
process.stdout.write(hideCursor());
|
|
185
199
|
}
|
|
186
|
-
|
|
187
|
-
|
|
200
|
+
tick();
|
|
201
|
+
await runPromise;
|
|
202
|
+
const interval = setInterval(tick, 2000);
|
|
188
203
|
process.on("SIGWINCH", () => {
|
|
189
204
|
terminalWidth = process.stdout.columns ?? terminalWidth;
|
|
190
205
|
});
|
package/dist/commands/tenant.js
CHANGED
|
@@ -17,7 +17,7 @@ function displayScopeError(error, retryCommand) {
|
|
|
17
17
|
p.note(`gh auth refresh --scopes ${scopeArg}\n\nThen re-run: ${retryCommand}`, "Fix missing scope");
|
|
18
18
|
}
|
|
19
19
|
function parseTenantAddFlags(args) {
|
|
20
|
-
const flags = { nonInteractive: false };
|
|
20
|
+
const flags = { nonInteractive: false, assignedOnly: false };
|
|
21
21
|
for (let i = 0; i < args.length; i += 1) {
|
|
22
22
|
const arg = args[i];
|
|
23
23
|
const next = args[i + 1];
|
|
@@ -33,6 +33,9 @@ function parseTenantAddFlags(args) {
|
|
|
33
33
|
flags.runtime = next;
|
|
34
34
|
i += 1;
|
|
35
35
|
break;
|
|
36
|
+
case "--assigned-only":
|
|
37
|
+
flags.assignedOnly = true;
|
|
38
|
+
break;
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
return flags;
|
|
@@ -143,6 +146,7 @@ async function tenantAddNonInteractive(flags, options) {
|
|
|
143
146
|
statusField,
|
|
144
147
|
mappings,
|
|
145
148
|
runtime,
|
|
149
|
+
assignedOnly: flags.assignedOnly,
|
|
146
150
|
});
|
|
147
151
|
if (options.json) {
|
|
148
152
|
process.stdout.write(JSON.stringify({ tenantId, status: "created" }) + "\n");
|
|
@@ -228,7 +232,7 @@ async function tenantAddInteractive(options) {
|
|
|
228
232
|
return;
|
|
229
233
|
}
|
|
230
234
|
const selectedProjectId = await abortIfCancelled(p.select({
|
|
231
|
-
message: "Step 1/
|
|
235
|
+
message: "Step 1/4 — Select a GitHub Project:",
|
|
232
236
|
options: projects.map((proj) => ({
|
|
233
237
|
value: proj.id,
|
|
234
238
|
label: `${proj.owner.login}/${proj.title}`,
|
|
@@ -256,7 +260,7 @@ async function tenantAddInteractive(options) {
|
|
|
256
260
|
return;
|
|
257
261
|
}
|
|
258
262
|
const selectedRepos = await abortIfCancelled(p.multiselect({
|
|
259
|
-
message: "Step 2/
|
|
263
|
+
message: "Step 2/4 — Select repositories to orchestrate:",
|
|
260
264
|
options: projectDetail.linkedRepositories.map((repo) => ({
|
|
261
265
|
value: repo,
|
|
262
266
|
label: `${repo.owner}/${repo.name}`,
|
|
@@ -287,9 +291,14 @@ async function tenantAddInteractive(options) {
|
|
|
287
291
|
}
|
|
288
292
|
const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
|
|
289
293
|
p.log.info(`Auto-detected workflow: Active=[${lifecycleConfig.activeStates.join(", ")}] Terminal=[${lifecycleConfig.terminalStates.join(", ")}]`);
|
|
290
|
-
// ── Step 4:
|
|
294
|
+
// ── Step 4: Assignment filter ────────────────────────────────────────────────
|
|
295
|
+
const assignedOnly = await abortIfCancelled(p.confirm({
|
|
296
|
+
message: `Step 3/4 — Only process issues assigned to the authenticated GitHub user?`,
|
|
297
|
+
initialValue: false,
|
|
298
|
+
}));
|
|
299
|
+
// ── Step 5: Runtime selection ────────────────────────────────────────────────
|
|
291
300
|
const runtime = await abortIfCancelled(p.select({
|
|
292
|
-
message: "Step
|
|
301
|
+
message: "Step 4/4 — Select AI runtime:",
|
|
293
302
|
options: [
|
|
294
303
|
{ value: "codex", label: "OpenAI Codex", hint: "recommended" },
|
|
295
304
|
{ value: "claude-code", label: "Claude Code" },
|
|
@@ -308,6 +317,7 @@ async function tenantAddInteractive(options) {
|
|
|
308
317
|
`User: ${login}`,
|
|
309
318
|
`Project: ${projectDetail.title}`,
|
|
310
319
|
`Repos: ${selectedRepos.map((r) => `${r.owner}/${r.name}`).join(", ")}`,
|
|
320
|
+
`Assigned: ${assignedOnly ? `Only issues assigned to ${login}` : "All project issues"}`,
|
|
311
321
|
`Runtime: ${runtime}`,
|
|
312
322
|
`Active: ${lifecycleConfig.activeStates.join(", ")}`,
|
|
313
323
|
`Terminal: ${lifecycleConfig.terminalStates.join(", ")}`,
|
|
@@ -335,6 +345,7 @@ async function tenantAddInteractive(options) {
|
|
|
335
345
|
mappings,
|
|
336
346
|
runtime,
|
|
337
347
|
agentCommand,
|
|
348
|
+
assignedOnly,
|
|
338
349
|
});
|
|
339
350
|
s6.stop("Configuration saved.");
|
|
340
351
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -7,7 +7,14 @@ export type CliGlobalConfig = {
|
|
|
7
7
|
activeTenant: string | null;
|
|
8
8
|
tenants: string[];
|
|
9
9
|
};
|
|
10
|
-
export type
|
|
10
|
+
export type CliTenantTrackerSettings = Record<string, string | boolean> & {
|
|
11
|
+
projectId?: string;
|
|
12
|
+
assignedOnly?: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type CliTenantConfig = Omit<OrchestratorTenantConfig, "tracker"> & {
|
|
15
|
+
tracker: Omit<OrchestratorTenantConfig["tracker"], "settings"> & {
|
|
16
|
+
settings?: CliTenantTrackerSettings;
|
|
17
|
+
};
|
|
11
18
|
workflowMapping?: WorkflowStateConfig;
|
|
12
19
|
};
|
|
13
20
|
export type StateRole = "active" | "wait" | "terminal";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ── Dashboard Renderer (Elixir-parity) ──────────────────────────────────────
|
|
2
2
|
import { bold, dim, green, red, yellow, cyan, magenta, blue, stripAnsi, } from "../ansi.js";
|
|
3
3
|
// ── Column widths (from Elixir spec) ─────────────────────────────────────────
|
|
4
|
-
const COL_ID =
|
|
4
|
+
const COL_ID = 24;
|
|
5
5
|
const COL_STAGE = 14;
|
|
6
6
|
const COL_PID = 8;
|
|
7
7
|
const COL_AGE_TURN = 12;
|
|
@@ -152,7 +152,7 @@ function tableHeaderRow(c) {
|
|
|
152
152
|
function activeRunRow(run, now, evtWidth, c) {
|
|
153
153
|
const dot = statusDot(run, c);
|
|
154
154
|
const id = pad(run.issueIdentifier, COL_ID);
|
|
155
|
-
const stage = pad(run.issueState, COL_STAGE);
|
|
155
|
+
const stage = pad(run.issueState || "\u2014", COL_STAGE);
|
|
156
156
|
const pid = pad(run.processId != null ? String(run.processId) : "\u2014", COL_PID);
|
|
157
157
|
const age = fmtAge(run.startedAt, now);
|
|
158
158
|
const turn = run.turnCount ?? 0;
|
package/dist/github/gh-auth.js
CHANGED
|
@@ -31,7 +31,7 @@ export function checkGhAuthenticated(opts) {
|
|
|
31
31
|
if ((result.status ?? 1) !== 0) {
|
|
32
32
|
return { authenticated: false };
|
|
33
33
|
}
|
|
34
|
-
const login = parseLogin((result.
|
|
34
|
+
const login = parseLogin((result.stdout ?? "").toString());
|
|
35
35
|
return { authenticated: true, login };
|
|
36
36
|
}
|
|
37
37
|
export function checkGhScopes(opts) {
|
|
@@ -40,7 +40,7 @@ export function checkGhScopes(opts) {
|
|
|
40
40
|
encoding: "utf8",
|
|
41
41
|
stdio: ["pipe", "pipe", "pipe"],
|
|
42
42
|
});
|
|
43
|
-
const output = (result.
|
|
43
|
+
const output = (result.stdout ?? "").toString();
|
|
44
44
|
const scopes = parseScopes(output);
|
|
45
45
|
if (scopes.length === 0) {
|
|
46
46
|
return { valid: true, missing: [], scopes: [] };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import { pathToFileURL } from "node:url";
|
|
3
4
|
import { resolveConfigDir } from "./config.js";
|
|
4
5
|
export function parseGlobalOptions(argv) {
|
|
@@ -81,7 +82,7 @@ async function main() {
|
|
|
81
82
|
await runCli(process.argv.slice(2));
|
|
82
83
|
}
|
|
83
84
|
if (process.argv[1] &&
|
|
84
|
-
import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
85
|
+
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
85
86
|
main().catch((error) => {
|
|
86
87
|
process.stderr.write(`${error instanceof Error ? error.message : "Unknown error"}\n`);
|
|
87
88
|
process.exitCode = 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gh-symphony/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "hojinzs",
|
|
6
6
|
"description": "Interactive CLI for GitHub Symphony orchestration",
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@clack/prompts": "^0.9.1",
|
|
39
|
-
"@gh-symphony/core": "0.0.
|
|
40
|
-
"@gh-symphony/orchestrator": "0.0.
|
|
41
|
-
"@gh-symphony/tracker-github": "0.0.
|
|
42
|
-
"@gh-symphony/worker": "0.0.
|
|
39
|
+
"@gh-symphony/core": "0.0.5",
|
|
40
|
+
"@gh-symphony/orchestrator": "0.0.5",
|
|
41
|
+
"@gh-symphony/tracker-github": "0.0.5",
|
|
42
|
+
"@gh-symphony/worker": "0.0.5"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsc -p tsconfig.json",
|