@gaia-ai/ui 0.6.0 → 0.6.2

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  # @gaia-ai/ui
2
2
 
3
- GAIA home-rooted cockpit: the `gaia ui` command plugin (renderer resolved dynamically).
3
+ GAIA project-first cockpit: the `gaia ui` command plugin (renderer resolved dynamically).
4
4
 
5
- Part of the GAIA conductor. Install the meta package `@gaia-ai/gaia` to get the `gaia` CLI with all plugins. Source: https://git.key-tec.de/keytec/gaia (conductor/).
5
+ Part of the GAIA CLI. Install the meta package `@gaia-ai/gaia` to get the `gaia` CLI with all addons. Source: https://git.key-tec.de/keytec/gaia (gaia-cli/).
@@ -1,2 +1,3 @@
1
+ export { type HereContext, isTicketIdentifier, isUuid, normalizeRepoUrl, type ParsedTarget, parseTarget, parseTicketIdentifierFromBranch, resolveHere, type TargetInputs, UI_VIEW_MODES, type UiInitialRoute, type UiViewMode, } from './route.js';
1
2
  export { cmdUi, default, type RunGaiaUiOptions, type UiDeps } from './ui.js';
2
- export { buildHomeConnection, type HomeConnection, resolveAgentCommand, resolveDefaultProject, resolveUiConfigPath, } from './ui-home.js';
3
+ export { type HomeConnection, type ProjectRooting, resolveAgentCommand, resolveDefaultProject, resolveProjectRooting, } from './ui-home.js';
package/dist/src/index.js CHANGED
@@ -1,5 +1,10 @@
1
- // GAIA-201: `@gaia-ai/ui` — the home-rooted `gaia ui` command plugin. The
2
- // default export is the `GaiaCommandPlugin` the host mounts; `cmdUi` + the
3
- // home-connection builders are exported for tests.
1
+ // GAIA-201 / GAIA-219: `@gaia-ai/ui` — the `gaia ui` command plugin. Its
2
+ // connection is resolved by core's `loadGaiaConfig` (explicit → project
3
+ // walk-up home shipped fallback), so running it inside a project uses that
4
+ // project's control plane; this package owns no precedence rule of its own.
5
+ // The default export is the `GaiaCommandPlugin` the host mounts; `cmdUi` + the
6
+ // bootstrap helpers are exported for tests.
7
+ // GAIA-223: the deep-link grammar (the pure CLI seam).
8
+ export { isTicketIdentifier, isUuid, normalizeRepoUrl, parseTarget, parseTicketIdentifierFromBranch, resolveHere, UI_VIEW_MODES, } from './route.js';
4
9
  export { cmdUi, default } from './ui.js';
5
- export { buildHomeConnection, resolveAgentCommand, resolveDefaultProject, resolveUiConfigPath, } from './ui-home.js';
10
+ export { resolveAgentCommand, resolveDefaultProject, resolveProjectRooting, } from './ui-home.js';
@@ -0,0 +1,115 @@
1
+ /** How a ticket target is rendered. Mirrors the Drupal `gaia_ticket` view modes. */
2
+ export type UiViewMode = 'full' | 'teaser' | 'board_card';
3
+ /** The known `--view` modes, in the order error messages list them. */
4
+ export declare const UI_VIEW_MODES: readonly UiViewMode[];
5
+ /**
6
+ * A deep-link target as the CLI understands it — *unresolved*: it says what to
7
+ * look up and how, never what screen results. Crosses both package boundaries
8
+ * (`RunGaiaUiOptions` → `GaiaUiLauncherOptions` → `TuiOptions`) and is mirrored
9
+ * structurally in the renderer, per the `HomeConnection` precedent (Finding 6).
10
+ */
11
+ export interface UiInitialRoute {
12
+ kind: 'ticket' | 'project' | 'run';
13
+ /**
14
+ * How `value` must be looked up. `'repo'` is the `--here` project rung: the
15
+ * value is a NORMALISED remote URL (`host/path`), matched client-side against
16
+ * every `gaia_project.repos[].url` (spec Decision 3).
17
+ */
18
+ by: 'uuid' | 'identifier' | 'name' | 'repo';
19
+ value: string;
20
+ /** Drupal view mode for a ticket target; default 'full'. */
21
+ view?: UiViewMode | undefined;
22
+ /** Set when the target came from `--here`; error messages only. */
23
+ fromBranch?: string | undefined;
24
+ }
25
+ /** The raw argv inputs `parseTarget` reads (commander's parsed options). */
26
+ export interface TargetInputs {
27
+ target?: string | undefined;
28
+ ticket?: string | undefined;
29
+ project?: string | undefined;
30
+ run?: string | undefined;
31
+ here?: boolean | undefined;
32
+ view?: string | undefined;
33
+ }
34
+ /**
35
+ * What the grammar made of the argv: nothing (the dashboard), a `--here`
36
+ * request that still needs the ladder walked, or a finished route.
37
+ */
38
+ export type ParsedTarget = {
39
+ kind: 'here';
40
+ view?: UiViewMode | undefined;
41
+ } | {
42
+ kind: 'route';
43
+ route: UiInitialRoute;
44
+ };
45
+ /** True for an `8-4-4-4-12` hex UUID (case-insensitive). */
46
+ export declare function isUuid(value: string): boolean;
47
+ /** True for a `KEY-123` ticket identifier (case-insensitive). */
48
+ export declare function isTicketIdentifier(value: string): boolean;
49
+ /**
50
+ * The ticket identifier a branch name carries, uppercased, or `null`.
51
+ * Pure and offline — the `--here` ticket rung never needs the network.
52
+ */
53
+ export declare function parseTicketIdentifierFromBranch(branch: string): string | null;
54
+ /**
55
+ * The grammar: at most ONE target source (the positional, `--ticket`,
56
+ * `--project`, `--run`, `--here`) plus the orthogonal `--view` modifier.
57
+ *
58
+ * Returns `undefined` for no target at all (the dashboard, AC-4), `{kind:'here'}`
59
+ * when the ladder still has to be walked, else the finished route. Throws — with
60
+ * a message naming exactly what went wrong — on a conflict or an unparseable
61
+ * form; the caller turns that into a stderr line and exit 1 before any network
62
+ * work happens.
63
+ */
64
+ export declare function parseTarget(inputs: TargetInputs): ParsedTarget | undefined;
65
+ /**
66
+ * A git remote URL reduced to a comparable `host/path` identity: scheme,
67
+ * userinfo, port and a trailing `.git` (and trailing slashes) dropped, the whole
68
+ * thing lowercased. Returns `undefined` for anything that carries no host+path.
69
+ *
70
+ * This is REQUIRED, not cosmetic (spec Decision 3): git and the control plane
71
+ * disagree on form. Measured in this repo — `git remote get-url origin` yields
72
+ * `git@git.key-tec.de:keytec/gaia.git` while `gaia_project.repos[].url` holds
73
+ * `ssh://git@git.key-tec.de/keytec/gaia.git`. Both must reduce to
74
+ * `git.key-tec.de/keytec/gaia` or the `--here` project rung never matches.
75
+ *
76
+ * | raw | normalised |
77
+ * |-----------------------------------------|---------------------|
78
+ * | `git@host:keytec/gaia.git` | `host/keytec/gaia` |
79
+ * | `ssh://git@host/keytec/gaia.git` | `host/keytec/gaia` |
80
+ * | `ssh://git@host:2222/keytec/gaia.git` | `host/keytec/gaia` |
81
+ * | `https://host/keytec/gaia.git` | `host/keytec/gaia` |
82
+ */
83
+ export declare function normalizeRepoUrl(raw: string | undefined): string | undefined;
84
+ /** Everything `resolveHere` needs, all injected — two git reads, nothing else. */
85
+ export interface HereContext {
86
+ cwd: string;
87
+ view?: UiViewMode | undefined;
88
+ /** The current branch of `cwd` (git resolves it per worktree). May reject. */
89
+ readBranch: (cwd: string) => Promise<string>;
90
+ /**
91
+ * The cwd's `origin` remote URL, raw. Git resolves it per worktree too, and a
92
+ * worktree shares its main clone's remote — which is exactly the right answer
93
+ * (spec Decision 3). May reject (not a repo, no `origin`).
94
+ */
95
+ readRemoteUrl: (cwd: string) => Promise<string>;
96
+ }
97
+ /**
98
+ * `--here`: derive the target from the cwd (spec Decision 3).
99
+ *
100
+ * ```
101
+ * branch carries a ticket identifier? → that TICKET (feat/gaia-223-… → GAIA-223)
102
+ * else: the cwd's git remote → the PROJECT(s) whose repos[] claim it
103
+ * else → a named error
104
+ * ```
105
+ *
106
+ * The BRANCH decides the first rung, not git mechanics: a worktree on a
107
+ * non-ticket branch and the main clone on a ticket branch both come out right.
108
+ *
109
+ * The project rung stops HERE at a normalised repo identity — matching it
110
+ * against `gaia_project.repos[]` needs a JSON:API client, which the entry does
111
+ * not have (Finding 1), so `by: 'repo'` travels to the renderer's `resolveRoute`
112
+ * and the ambiguity ruling (one match / a picker / an error) is made there
113
+ * (Decisions 5 + 14).
114
+ */
115
+ export declare function resolveHere(ctx: HereContext): Promise<UiInitialRoute>;
@@ -0,0 +1,264 @@
1
+ // GAIA-223: the pure CLI seam of `gaia ui` deep links.
2
+ //
3
+ // This module owns the *grammar* — argv in, an unresolved `UiInitialRoute` out —
4
+ // and the `--here` ladder. It performs no I/O of its own: git, the filesystem
5
+ // and the conductor registry all enter through injected seams, so the whole
6
+ // grammar is testable offline and a conflict/parse failure costs no network
7
+ // round-trip (spec Decisions 1, 3, 4, 6, 13).
8
+ //
9
+ // Resolving a route against the control plane is deliberately NOT here: the
10
+ // entry has no JSON:API client (spec Finding 1). That happens in the renderer,
11
+ // in `@gaia-ai/addon-gaia-ui`'s `resolveRoute`, still before the TUI takes the
12
+ // terminal (spec Decision 5).
13
+ /** The known `--view` modes, in the order error messages list them. */
14
+ export const UI_VIEW_MODES = [
15
+ 'full',
16
+ 'teaser',
17
+ 'board_card',
18
+ ];
19
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
+ /** `<project-key>-<number>`, case-insensitive (Decision 4). One dash only, so a
21
+ * UUID can never match it. */
22
+ const IDENTIFIER_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
23
+ /**
24
+ * The ticket identifier carried by a branch name, first match wins. Every
25
+ * convention places the identifier before the title slug and `capBranchName()`
26
+ * never truncates it (Finding 3), so the first match is the right one.
27
+ */
28
+ const BRANCH_IDENTIFIER_RE = /(?:^|[/_-])([A-Za-z][A-Za-z0-9]*-\d+)(?=$|[/_-])/;
29
+ const ACCEPTED_FORMS = "accepted forms: GAIA-221 (a ticket identifier), 'ticket:<id|uuid>', " +
30
+ "'project:<name|uuid>', 'run:<uuid>'";
31
+ /** True for an `8-4-4-4-12` hex UUID (case-insensitive). */
32
+ export function isUuid(value) {
33
+ return UUID_RE.test(value);
34
+ }
35
+ /** True for a `KEY-123` ticket identifier (case-insensitive). */
36
+ export function isTicketIdentifier(value) {
37
+ return IDENTIFIER_RE.test(value);
38
+ }
39
+ /**
40
+ * The ticket identifier a branch name carries, uppercased, or `null`.
41
+ * Pure and offline — the `--here` ticket rung never needs the network.
42
+ */
43
+ export function parseTicketIdentifierFromBranch(branch) {
44
+ const match = BRANCH_IDENTIFIER_RE.exec(branch);
45
+ return match?.[1] ? match[1].toUpperCase() : null;
46
+ }
47
+ /** Validate a raw `--view` value against the known modes. */
48
+ function parseViewMode(raw) {
49
+ if (raw === undefined)
50
+ return undefined;
51
+ if (UI_VIEW_MODES.includes(raw)) {
52
+ return raw;
53
+ }
54
+ throw new Error(`gaia ui: unknown --view mode '${raw}'. Known modes: ${UI_VIEW_MODES.join(', ')}.`);
55
+ }
56
+ /** A ticket target from a raw value: a UUID by id, else an identifier by name. */
57
+ function ticketTarget(value) {
58
+ if (isUuid(value))
59
+ return { kind: 'ticket', by: 'uuid', value };
60
+ if (isTicketIdentifier(value)) {
61
+ return { kind: 'ticket', by: 'identifier', value: value.toUpperCase() };
62
+ }
63
+ throw new Error(`gaia ui: '${value}' is not a ticket identifier or UUID — ${ACCEPTED_FORMS}.`);
64
+ }
65
+ /** A project target: a UUID by id, else the label verbatim (labels are
66
+ * case-sensitive Drupal entity labels — Decision 4). */
67
+ function projectTarget(value) {
68
+ return isUuid(value)
69
+ ? { kind: 'project', by: 'uuid', value }
70
+ : { kind: 'project', by: 'name', value };
71
+ }
72
+ /** A run target — UUIDs only; there is no human-readable run handle. */
73
+ function runTarget(value) {
74
+ if (!isUuid(value)) {
75
+ throw new Error(`gaia ui: a run target must be a UUID, got '${value}'.`);
76
+ }
77
+ return { kind: 'run', by: 'uuid', value };
78
+ }
79
+ /** Parse the positional form: an optional `kind:` prefix, else a bare identifier. */
80
+ function parsePositional(raw) {
81
+ const colon = raw.indexOf(':');
82
+ if (colon >= 0) {
83
+ const prefix = raw.slice(0, colon);
84
+ const value = raw.slice(colon + 1);
85
+ if (prefix !== 'ticket' && prefix !== 'project' && prefix !== 'run') {
86
+ throw new Error(`gaia ui: unknown target kind '${prefix}'. ` +
87
+ "Known kinds: 'ticket:', 'project:', 'run:'.");
88
+ }
89
+ if (value === '') {
90
+ throw new Error(`gaia ui: '${raw}' has an empty value — ${ACCEPTED_FORMS}.`);
91
+ }
92
+ if (prefix === 'ticket')
93
+ return ticketTarget(value);
94
+ if (prefix === 'project')
95
+ return projectTarget(value);
96
+ return runTarget(value);
97
+ }
98
+ // Bare: strictly a ticket identifier. A UUID is ambiguous by construction —
99
+ // it does not say which kind — and never guessed (Decision 1).
100
+ if (isUuid(raw)) {
101
+ throw new Error(`gaia ui: '${raw}' is ambiguous — a bare UUID does not say which kind. ` +
102
+ "Use 'ticket:<uuid>', 'project:<uuid>' or 'run:<uuid>'.");
103
+ }
104
+ return ticketTarget(raw);
105
+ }
106
+ /**
107
+ * The grammar: at most ONE target source (the positional, `--ticket`,
108
+ * `--project`, `--run`, `--here`) plus the orthogonal `--view` modifier.
109
+ *
110
+ * Returns `undefined` for no target at all (the dashboard, AC-4), `{kind:'here'}`
111
+ * when the ladder still has to be walked, else the finished route. Throws — with
112
+ * a message naming exactly what went wrong — on a conflict or an unparseable
113
+ * form; the caller turns that into a stderr line and exit 1 before any network
114
+ * work happens.
115
+ */
116
+ export function parseTarget(inputs) {
117
+ const supplied = [];
118
+ if (inputs.target !== undefined && inputs.target !== '') {
119
+ supplied.push(`the positional target '${inputs.target}'`);
120
+ }
121
+ if (inputs.ticket !== undefined)
122
+ supplied.push('--ticket');
123
+ if (inputs.project !== undefined)
124
+ supplied.push('--project');
125
+ if (inputs.run !== undefined)
126
+ supplied.push('--run');
127
+ if (inputs.here === true)
128
+ supplied.push('--here');
129
+ if (supplied.length > 1) {
130
+ throw new Error(`gaia ui: conflicting targets — ${supplied.join(', ')} were all supplied. ` +
131
+ 'Give at most one of: a positional target, --ticket, --project, --run, --here.');
132
+ }
133
+ const view = parseViewMode(inputs.view);
134
+ if (supplied.length === 0)
135
+ return undefined;
136
+ if (inputs.here === true) {
137
+ return { kind: 'here', view: view ?? 'full' };
138
+ }
139
+ const target = inputs.target !== undefined && inputs.target !== ''
140
+ ? parsePositional(inputs.target)
141
+ : inputs.ticket !== undefined
142
+ ? ticketTarget(inputs.ticket)
143
+ : inputs.project !== undefined
144
+ ? projectTarget(inputs.project)
145
+ : runTarget(inputs.run);
146
+ // `--view` describes how a TICKET renders. A project screen has no view mode,
147
+ // and `run:` already means "the ticket's Runs tab", so pairing it with a
148
+ // compact block is contradictory rather than resolvable (Decision 13).
149
+ if (view !== undefined && target.kind === 'project') {
150
+ throw new Error('gaia ui: --view applies to ticket targets only, but the target is a project. ' +
151
+ 'Drop --view, or name a ticket.');
152
+ }
153
+ if (view !== undefined && target.kind === 'run') {
154
+ throw new Error("gaia ui: --view cannot be combined with a 'run:' target — 'run:' already " +
155
+ "means the owning ticket's Runs tab. Use 'ticket:<id> --view <mode>' instead.");
156
+ }
157
+ return {
158
+ kind: 'route',
159
+ route: target.kind === 'project' ? target : { ...target, view: view ?? 'full' },
160
+ };
161
+ }
162
+ /**
163
+ * A git remote URL reduced to a comparable `host/path` identity: scheme,
164
+ * userinfo, port and a trailing `.git` (and trailing slashes) dropped, the whole
165
+ * thing lowercased. Returns `undefined` for anything that carries no host+path.
166
+ *
167
+ * This is REQUIRED, not cosmetic (spec Decision 3): git and the control plane
168
+ * disagree on form. Measured in this repo — `git remote get-url origin` yields
169
+ * `git@git.key-tec.de:keytec/gaia.git` while `gaia_project.repos[].url` holds
170
+ * `ssh://git@git.key-tec.de/keytec/gaia.git`. Both must reduce to
171
+ * `git.key-tec.de/keytec/gaia` or the `--here` project rung never matches.
172
+ *
173
+ * | raw | normalised |
174
+ * |-----------------------------------------|---------------------|
175
+ * | `git@host:keytec/gaia.git` | `host/keytec/gaia` |
176
+ * | `ssh://git@host/keytec/gaia.git` | `host/keytec/gaia` |
177
+ * | `ssh://git@host:2222/keytec/gaia.git` | `host/keytec/gaia` |
178
+ * | `https://host/keytec/gaia.git` | `host/keytec/gaia` |
179
+ */
180
+ export function normalizeRepoUrl(raw) {
181
+ const trimmed = (raw ?? '').trim();
182
+ if (trimmed === '')
183
+ return undefined;
184
+ let host;
185
+ let path;
186
+ // `scheme://[user@]host[:port]/path` — checked FIRST, so an `ssh://…:2222/…`
187
+ // port is never mistaken for the scp form's `host:path` colon.
188
+ const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(.+)$/.exec(trimmed);
189
+ if (withScheme?.[1] !== undefined) {
190
+ const rest = withScheme[1];
191
+ const slash = rest.indexOf('/');
192
+ if (slash < 0)
193
+ return undefined;
194
+ host = rest.slice(0, slash);
195
+ path = rest.slice(slash + 1);
196
+ }
197
+ else {
198
+ // The scp-like form `[user@]host:path` — what `git remote get-url` emits
199
+ // for an ssh clone. A bare local path has no colon and falls out here.
200
+ const scp = /^(?:[^/@]+@)?([^/:]+):(.+)$/.exec(trimmed);
201
+ if (scp?.[1] === undefined || scp[2] === undefined)
202
+ return undefined;
203
+ host = scp[1];
204
+ path = scp[2];
205
+ }
206
+ host = host
207
+ .replace(/^[^@]*@/, '')
208
+ .replace(/:\d+$/, '')
209
+ .toLowerCase();
210
+ path = path
211
+ .replace(/^\/+/, '')
212
+ .replace(/\/+$/, '')
213
+ .replace(/\.git$/i, '')
214
+ .replace(/\/+$/, '')
215
+ .toLowerCase();
216
+ if (host === '' || path === '')
217
+ return undefined;
218
+ return `${host}/${path}`;
219
+ }
220
+ /**
221
+ * `--here`: derive the target from the cwd (spec Decision 3).
222
+ *
223
+ * ```
224
+ * branch carries a ticket identifier? → that TICKET (feat/gaia-223-… → GAIA-223)
225
+ * else: the cwd's git remote → the PROJECT(s) whose repos[] claim it
226
+ * else → a named error
227
+ * ```
228
+ *
229
+ * The BRANCH decides the first rung, not git mechanics: a worktree on a
230
+ * non-ticket branch and the main clone on a ticket branch both come out right.
231
+ *
232
+ * The project rung stops HERE at a normalised repo identity — matching it
233
+ * against `gaia_project.repos[]` needs a JSON:API client, which the entry does
234
+ * not have (Finding 1), so `by: 'repo'` travels to the renderer's `resolveRoute`
235
+ * and the ambiguity ruling (one match / a picker / an error) is made there
236
+ * (Decisions 5 + 14).
237
+ */
238
+ export async function resolveHere(ctx) {
239
+ const branch = await ctx.readBranch(ctx.cwd).then((b) => b.trim(), () => '');
240
+ const identifier = branch === '' ? null : parseTicketIdentifierFromBranch(branch);
241
+ if (identifier !== null) {
242
+ return {
243
+ kind: 'ticket',
244
+ by: 'identifier',
245
+ value: identifier,
246
+ view: ctx.view ?? 'full',
247
+ fromBranch: branch,
248
+ };
249
+ }
250
+ const remote = await ctx.readRemoteUrl(ctx.cwd).then((r) => r.trim(), () => '');
251
+ const repo = normalizeRepoUrl(remote);
252
+ if (repo === undefined) {
253
+ const got = remote === '' ? '' : `, got '${remote}'`;
254
+ throw new Error(`gaia ui --here: could not derive a project from '${ctx.cwd}' — ` +
255
+ `no usable git remote (looked at 'origin'${got}).\n` +
256
+ " Run 'gaia ui' for the dashboard, or 'gaia ui project:<name>' to open one directly.");
257
+ }
258
+ return {
259
+ kind: 'project',
260
+ by: 'repo',
261
+ value: repo,
262
+ ...(branch !== '' ? { fromBranch: branch } : {}),
263
+ };
264
+ }
@@ -1,7 +1,7 @@
1
1
  import { type ConductorRegistryEntry, type MachineContext } from '@gaia-ai/core';
2
2
  /**
3
- * The resolved home control-plane connection passed to the renderer launcher.
4
- * A structural mirror of `@gaia-ai/plugin-gaia-ui`'s `HomeConnection` — kept
3
+ * The resolved control-plane connection passed to the renderer launcher.
4
+ * A structural mirror of `@gaia-ai/addon-gaia-ui`'s `HomeConnection` — kept
5
5
  * local so this package needs NO compile-time dependency on the renderer (it
6
6
  * resolves the renderer dynamically, ESLint-style, at runtime).
7
7
  */
@@ -12,30 +12,6 @@ export interface HomeConnection {
12
12
  configPath?: string | undefined;
13
13
  authProfile?: string | undefined;
14
14
  }
15
- /** The `session` oauth2 client-credentials descriptor, matching the connection
16
- * config template shape exactly so home reads authenticate identically to a
17
- * project-rooted `gaia dropsh`. */
18
- export interface SessionAuthDescriptor {
19
- plugin: '@dropsh/plugin-oauth2';
20
- export: 'oauth2Plugin';
21
- with: {
22
- id: 'session';
23
- default: true;
24
- type: 'oauth2_client_credentials';
25
- client_id: string;
26
- client_secret: string;
27
- token_url: string;
28
- scope: 'gaia:session';
29
- };
30
- }
31
- /**
32
- * Build the home control-plane connection from the machine context. Throws an
33
- * actionable error when the context is missing `base_url` / `client_id` /
34
- * `client_secret` (there is no project config to fall back to). The returned
35
- * `authPlugins` carries a single `session` oauth2 descriptor; `configPath` is
36
- * filled in by the entry once it has resolved the connection config file.
37
- */
38
- export declare function buildHomeConnection(machine: Partial<MachineContext>): HomeConnection;
39
15
  /**
40
16
  * Resolve the default project (opaque group) from the conductor registry: the
41
17
  * `preferred` project when it is registered, else the first entry's project.
@@ -51,15 +27,38 @@ export declare function resolveDefaultProject(entries: ConductorRegistryEntry[],
51
27
  export declare function resolveAgentCommand(machine: Partial<MachineContext> & {
52
28
  agent_command?: string;
53
29
  }): (prompt: string) => string;
30
+ /** Where `gaia ui` roots the agents it launches, and which project they belong to. */
31
+ export interface ProjectRooting {
32
+ cwd: string;
33
+ project?: string | undefined;
34
+ ownConductorMachineId?: string | undefined;
35
+ }
54
36
  /**
55
- * Resolve which connection config `gaia ui` reads. A user-authored home
56
- * `~/.gaia/gaia.config.js` (`userConfigPath`) wins — a real home-level cockpit
57
- * connection used verbatim (`fallback: false`). Otherwise the shipped
58
- * machine-context fallback (`fallbackPath`) is used (`fallback: true`), which
59
- * requires a complete machine context. Pure: the caller does the
60
- * `$DROPSH_CONFIG` / `$GAIA_MACHINE_CONTEXT` wiring.
37
+ * Decide the agents' working directory + project (GAIA-219 AC-9).
38
+ *
39
+ * The trigger is the **cwd**, deliberately not the winning connection config:
40
+ * "which connection did we resolve" and "which project am I standing in" are
41
+ * different questions, and binding them would let an explicit `--config`
42
+ * silently relocate the agents' working directory.
43
+ *
44
+ * Standing **in** a project (core's walk-up finds a `.gaia/` project dir that
45
+ * is not the user's own `~/.gaia`), the cwd is that repo's **root** and the
46
+ * matching registry entry supplies the project + machine id; a project with no
47
+ * registry entry still roots at its repo root. Otherwise the registry default
48
+ * applies verbatim, exactly as before this ticket.
49
+ *
50
+ * The marker is core's `findProjectGaiaDir` — "is there a `.gaia/` here" —
51
+ * NOT the connection walk-up `findGaiaConfig`. Since GAIA-230 the latter skips
52
+ * an engine-only `conductor.config.js`, which GAIA-218 makes the canonical repo
53
+ * shape, so keying on it would leave the most common project rooted in the
54
+ * registry default. Which connection won and which project we stand in stay two
55
+ * separate questions, as this function's whole point requires.
56
+ *
57
+ * `~/.gaia` is excluded for the same reason core labels it `home`: the walk-up
58
+ * reaches it from every directory under `$HOME`, so counting it would make
59
+ * every such directory look like "a project rooted at `$HOME`".
60
+ *
61
+ * Pure with respect to precedence — the walk-up is core's; this helper owns no
62
+ * filesystem probe of its own.
61
63
  */
62
- export declare function resolveUiConfigPath(userConfigPath: string, fallbackPath: string): {
63
- path: string;
64
- fallback: boolean;
65
- };
64
+ export declare function resolveProjectRooting(cwd: string, entries: ConductorRegistryEntry[], homeDir: string): ProjectRooting;
@@ -1,51 +1,10 @@
1
- // GAIA-201 (was GAIA-194): home-rooted bootstrap builders for `gaia ui`. Pure
2
- // functions that turn the user-global machine context (identity + connection) +
3
- // the conductor registry into everything `runGaiaUi` needs WITHOUT a project
4
- // engine `conductor.config.js`. The `gaia ui` entry (`ui.ts`) wires them.
5
- import { existsSync } from 'node:fs';
6
- import { shellQuote, } from '@gaia-ai/core';
7
- /**
8
- * Build the home control-plane connection from the machine context. Throws an
9
- * actionable error when the context is missing `base_url` / `client_id` /
10
- * `client_secret` (there is no project config to fall back to). The returned
11
- * `authPlugins` carries a single `session` oauth2 descriptor; `configPath` is
12
- * filled in by the entry once it has resolved the connection config file.
13
- */
14
- export function buildHomeConnection(machine) {
15
- const baseUrl = (machine.base_url ?? '').trim();
16
- const clientId = (machine.client_id ?? '').trim();
17
- const clientSecret = (machine.client_secret ?? '').trim();
18
- const missing = [];
19
- if (baseUrl === '')
20
- missing.push('base_url');
21
- if (clientId === '')
22
- missing.push('client_id');
23
- if (clientSecret === '')
24
- missing.push('client_secret');
25
- if (missing.length > 0) {
26
- throw new Error(`gaia ui: the machine context is missing ${missing.join(', ')} — ` +
27
- `run 'gaia conductor init --base-url <url>' to onboard this machine.`);
28
- }
29
- const descriptor = {
30
- plugin: '@dropsh/plugin-oauth2',
31
- export: 'oauth2Plugin',
32
- with: {
33
- id: 'session',
34
- default: true,
35
- type: 'oauth2_client_credentials',
36
- client_id: clientId,
37
- client_secret: clientSecret,
38
- token_url: `${baseUrl}/oauth/token`,
39
- scope: 'gaia:session',
40
- },
41
- };
42
- return {
43
- baseUrl,
44
- jsonapiPrefix: '/jsonapi',
45
- authPlugins: [descriptor],
46
- authProfile: 'session',
47
- };
48
- }
1
+ // GAIA-201 (was GAIA-194), GAIA-219: bootstrap builders for `gaia ui`. Pure
2
+ // functions that turn the machine context (identity), the conductor registry
3
+ // and the cwd into everything `runGaiaUi` needs. The CONNECTION itself is not
4
+ // built here `ui.ts` asks core's `loadGaiaConfig` for it, so this package
5
+ // holds no connection-precedence rule of its own (AC-6).
6
+ import { dirname, resolve } from 'node:path';
7
+ import { findProjectGaiaDir, homeGaiaDir, shellQuote, } from '@gaia-ai/core';
49
8
  /**
50
9
  * Resolve the default project (opaque group) from the conductor registry: the
51
10
  * `preferred` project when it is registered, else the first entry's project.
@@ -68,15 +27,52 @@ export function resolveAgentCommand(machine) {
68
27
  return (prompt) => prompt.trim() === '' ? bin : `${bin} ${shellQuote(prompt)}`;
69
28
  }
70
29
  /**
71
- * Resolve which connection config `gaia ui` reads. A user-authored home
72
- * `~/.gaia/gaia.config.js` (`userConfigPath`) wins — a real home-level cockpit
73
- * connection used verbatim (`fallback: false`). Otherwise the shipped
74
- * machine-context fallback (`fallbackPath`) is used (`fallback: true`), which
75
- * requires a complete machine context. Pure: the caller does the
76
- * `$DROPSH_CONFIG` / `$GAIA_MACHINE_CONTEXT` wiring.
30
+ * Decide the agents' working directory + project (GAIA-219 AC-9).
31
+ *
32
+ * The trigger is the **cwd**, deliberately not the winning connection config:
33
+ * "which connection did we resolve" and "which project am I standing in" are
34
+ * different questions, and binding them would let an explicit `--config`
35
+ * silently relocate the agents' working directory.
36
+ *
37
+ * Standing **in** a project (core's walk-up finds a `.gaia/` project dir that
38
+ * is not the user's own `~/.gaia`), the cwd is that repo's **root** and the
39
+ * matching registry entry supplies the project + machine id; a project with no
40
+ * registry entry still roots at its repo root. Otherwise the registry default
41
+ * applies verbatim, exactly as before this ticket.
42
+ *
43
+ * The marker is core's `findProjectGaiaDir` — "is there a `.gaia/` here" —
44
+ * NOT the connection walk-up `findGaiaConfig`. Since GAIA-230 the latter skips
45
+ * an engine-only `conductor.config.js`, which GAIA-218 makes the canonical repo
46
+ * shape, so keying on it would leave the most common project rooted in the
47
+ * registry default. Which connection won and which project we stand in stay two
48
+ * separate questions, as this function's whole point requires.
49
+ *
50
+ * `~/.gaia` is excluded for the same reason core labels it `home`: the walk-up
51
+ * reaches it from every directory under `$HOME`, so counting it would make
52
+ * every such directory look like "a project rooted at `$HOME`".
53
+ *
54
+ * Pure with respect to precedence — the walk-up is core's; this helper owns no
55
+ * filesystem probe of its own.
77
56
  */
78
- export function resolveUiConfigPath(userConfigPath, fallbackPath) {
79
- return existsSync(userConfigPath)
80
- ? { path: userConfigPath, fallback: false }
81
- : { path: fallbackPath, fallback: true };
57
+ export function resolveProjectRooting(cwd, entries, homeDir) {
58
+ const gaiaDir = findProjectGaiaDir(cwd);
59
+ // Core owns where `.gaia` lives; the ui never spells the path itself.
60
+ const homeDotGaia = resolve(homeGaiaDir(homeDir));
61
+ if (gaiaDir !== undefined && resolve(gaiaDir) !== homeDotGaia) {
62
+ // Registry entries store the `.gaia` dir (the conductor's checkoutRoot),
63
+ // so that — not the repo root — is what an entry is matched on.
64
+ const entry = entries.find((e) => resolve(e.path) === resolve(gaiaDir));
65
+ return {
66
+ cwd: dirname(gaiaDir),
67
+ project: entry?.project,
68
+ ownConductorMachineId: entry?.id,
69
+ };
70
+ }
71
+ const project = resolveDefaultProject(entries);
72
+ const projectEntry = entries.find((e) => e.project === project);
73
+ return {
74
+ cwd: projectEntry?.path ?? homeDir,
75
+ project,
76
+ ownConductorMachineId: projectEntry?.id,
77
+ };
82
78
  }
package/dist/src/ui.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { type ConductorRegistryEntry, type GaiaCommandHost, type GaiaCommandPlugin, type MachineContext } from '@gaia-ai/core';
2
- import { type AgentLaunchHost } from '@gaia-ai/plugin-herdr';
1
+ import { type AgentLaunchHost } from '@gaia-ai/addon-herdr';
2
+ import { type ConductorRegistryEntry, type GaiaCommandHost, type GaiaCommandPlugin, type GaiaConnectionConfig, type MachineContext } from '@gaia-ai/core';
3
+ import { type UiInitialRoute } from './route.js';
3
4
  import { type HomeConnection } from './ui-home.js';
4
5
  /** Prompt-level agent service the renderer consumes (mirror of gaia-ui's `TuiAgentService`). */
5
6
  interface TuiAgentService {
@@ -43,6 +44,9 @@ export interface RunGaiaUiOptions {
43
44
  plugins: unknown[];
44
45
  }) => DropshProgram;
45
46
  renderer?: ((opts: unknown) => unknown) | undefined;
47
+ /** GAIA-223: the deep-link target, still UNRESOLVED (the entry has no JSON:API
48
+ * client — Finding 1). Absent ⇒ the renderer boots its dashboard (AC-4). */
49
+ initialRoute?: UiInitialRoute | undefined;
46
50
  }
47
51
  /** Test/composition seams for `gaia ui`. */
48
52
  export interface UiDeps {
@@ -54,18 +58,49 @@ export interface UiDeps {
54
58
  agentHost?: AgentLaunchHost;
55
59
  /** The renderer launcher; default: the dynamically-resolved `runGaiaUi`. */
56
60
  runUi?: (opts: RunGaiaUiOptions) => Promise<void>;
57
- /** Pre-resolved home connection config path; default: resolved from ~/.gaia or the shipped fallback. */
58
- homeConfigPath?: string;
59
61
  /** Host contract (resolveBases) for renderer resolution; default: cwd + this install. */
60
62
  host?: GaiaCommandHost;
63
+ /** Explicit connection config (`--config`); beats project + home (AC-4). */
64
+ explicitConfig?: string;
65
+ /** Where the resolution + the rooting start; default: `process.cwd()`. */
66
+ cwd?: string;
67
+ /** Connection loader; default: core's `loadGaiaConfig` (the ONE rule). */
68
+ loadConnection?: (host: GaiaCommandHost, opts: {
69
+ cwd?: string;
70
+ explicit?: string | undefined;
71
+ }) => Promise<GaiaConnectionConfig>;
72
+ /** Print the resolved connection and exit without launching the TUI (AC-7). */
73
+ printConfig?: boolean;
74
+ /** Sink for `--print-config`; default: `process.stdout.write`. */
75
+ stdout?: (s: string) => void;
76
+ /** Positional `[target]` (`GAIA-221`, `ticket:…`, `project:…`, `run:…`). */
77
+ target?: string | undefined;
78
+ /** `--ticket <id>`. */
79
+ ticket?: string | undefined;
80
+ /** `--project <id>`. */
81
+ project?: string | undefined;
82
+ /** `--run <uuid>`. */
83
+ run?: string | undefined;
84
+ /** `--here` — derive the target from the cwd (Decision 3). */
85
+ here?: boolean | undefined;
86
+ /** `--view <mode>` — how a ticket target renders (Decision 13). */
87
+ view?: string | undefined;
88
+ /** Current git branch of a dir; default `git branch --show-current`. */
89
+ readBranch?: (cwd: string) => Promise<string>;
90
+ /** The dir's `origin` remote URL; default `git remote get-url origin`. */
91
+ readRemoteUrl?: (cwd: string) => Promise<string>;
92
+ /** Where user-facing grammar errors go; default `process.stderr`. */
93
+ stderr?: (line: string) => void;
61
94
  }
62
95
  /**
63
- * `gaia ui` — launch the home-rooted interactive terminal UI. Reads the
64
- * user-global machine context + the conductor registry (NOT a project engine
65
- * config), resolves the connection from the home `gaia.config.js` scope, adapts
66
- * a herdr-backed agent host to the renderer's prompt-level service, and
67
- * delegates to `@gaia-ai/plugin-gaia-ui`'s `runGaiaUi`. `$GAIA_UI_PLUGIN`
68
- * overrides the renderer module (dev).
96
+ * `gaia ui` — launch the interactive terminal UI. Resolves the connection
97
+ * through core's `loadGaiaConfig` (explicit project walk-up home shipped
98
+ * fallback), roots the agents in the project it was started in, reads the
99
+ * machine context for identity + the agent binary, adapts a herdr-backed agent
100
+ * host to the renderer's prompt-level service, and delegates to
101
+ * `@gaia-ai/addon-gaia-ui`'s `runGaiaUi`. `--print-config` reports the
102
+ * resolution and exits without launching. `$GAIA_UI_PLUGIN` overrides the
103
+ * renderer module (dev).
69
104
  */
70
105
  export declare function cmdUi(deps?: UiDeps): Promise<void>;
71
106
  /** The `ui` command plugin the host mounts (`gaia ui`). */
package/dist/src/ui.js CHANGED
@@ -1,18 +1,87 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
3
  import { pathToFileURL } from 'node:url';
5
- import { CommandRunner, createLogger, exec, homeFallbackGaiaConfigPath, listRegisteredConductors, machineContextPath, readMachineContext, setDefaultCommandRunner, } from '@gaia-ai/core';
6
- import { herdrAgentHost } from '@gaia-ai/plugin-herdr';
4
+ import { herdrAgentHost } from '@gaia-ai/addon-herdr';
5
+ import { CommandRunner, createLogger, exec, listRegisteredConductors, loadGaiaConfig, machineContextPath, readMachineContext, setDefaultCommandRunner, } from '@gaia-ai/core';
7
6
  import { buildProgram as buildDropshProgram } from 'dropsh';
8
- import { buildHomeConnection, resolveAgentCommand, resolveDefaultProject, resolveUiConfigPath, } from './ui-home.js';
7
+ import { parseTarget, resolveHere, UI_VIEW_MODES, } from './route.js';
8
+ import { resolveAgentCommand, resolveProjectRooting, } from './ui-home.js';
9
+ /**
10
+ * The current branch of `cwd`. Git resolves this PER WORKTREE, so
11
+ * worktree-awareness for `--here` costs nothing (Decision 3). Never throws:
12
+ * outside a repo, or on a detached HEAD, it yields '' and the ladder falls
13
+ * through to its project rung.
14
+ */
15
+ async function readCurrentBranch(cwd) {
16
+ try {
17
+ const out = await exec('git', ['branch', '--show-current'], { cwd });
18
+ return typeof out === 'string' ? out.trim() : String(out ?? '').trim();
19
+ }
20
+ catch {
21
+ return '';
22
+ }
23
+ }
24
+ /**
25
+ * The cwd's `origin` remote URL, raw. Git resolves it per worktree, and a
26
+ * worktree shares its main clone's remote — exactly the right answer for
27
+ * "which repo am I standing in" (Decision 3). Never throws: outside a repo, or
28
+ * with no `origin`, it yields '' and the ladder produces its named error.
29
+ *
30
+ * Revision 4 replaced the previous rung (an `import()` of the repo's engine
31
+ * `conductor.config.js` plus a conductor-registry longest-prefix fallback) with
32
+ * this one read: the registry cannot answer inside a worktree (its paths point
33
+ * at `.gaia` DIRS, and a worktree has its own unregistered one), the config read
34
+ * needed a dynamic import of a possibly secret-bearing module, and the remote
35
+ * works in a fresh clone that has no `.gaia/` at all.
36
+ */
37
+ async function readOriginRemoteUrl(cwd) {
38
+ try {
39
+ const out = await exec('git', ['remote', 'get-url', 'origin'], { cwd });
40
+ return typeof out === 'string' ? out.trim() : String(out ?? '').trim();
41
+ }
42
+ catch {
43
+ return '';
44
+ }
45
+ }
46
+ /**
47
+ * The explicit connection leg (AC-4, D2): `--config` → `$GAIA_CONFIG` →
48
+ * `$DROPSH_CONFIG`, first non-empty wins. The two env vars are read because
49
+ * core's resolver already names all three as the explicit leg and the renderer's
50
+ * launcher already honours a pre-set `$DROPSH_CONFIG` — so the path `gaia ui`
51
+ * reports and the file dropsh loads can never disagree.
52
+ */
53
+ function resolveExplicitConfig(flag) {
54
+ for (const candidate of [
55
+ flag,
56
+ process.env.GAIA_CONFIG,
57
+ process.env.DROPSH_CONFIG,
58
+ ]) {
59
+ if (candidate !== undefined && candidate.trim() !== '')
60
+ return candidate;
61
+ }
62
+ return undefined;
63
+ }
64
+ /** The `--print-config` block (AC-7): which file won, and what it points at. */
65
+ function formatConnectionReport(connection, rooting) {
66
+ const yesNo = (b) => (b ? 'yes' : 'no');
67
+ const lines = [
68
+ ['connection', connection.config_path],
69
+ ['source', connection.source],
70
+ ['fallback', yesNo(connection.fallback)],
71
+ ['legacy', yesNo(connection.legacy)],
72
+ ['base_url', connection.site.base_url],
73
+ ['cwd', rooting.cwd],
74
+ ['project', rooting.project ?? '(none)'],
75
+ ];
76
+ return `${lines.map(([k, v]) => `${`${k}:`.padEnd(12)}${v}`).join('\n')}\n`;
77
+ }
9
78
  function loggerFor(checkoutRoot) {
10
79
  const logger = createLogger({ checkoutRoot });
11
80
  setDefaultCommandRunner(new CommandRunner(logger));
12
81
  return logger;
13
82
  }
14
83
  /**
15
- * Resolve + dynamically import `@gaia-ai/plugin-gaia-ui` (the renderer launcher),
84
+ * Resolve + dynamically import `@gaia-ai/addon-gaia-ui` (the renderer launcher),
16
85
  * ESLint-style over the host bases. This package never statically imports the
17
86
  * renderer — it is a swappable plugin. Returns undefined (and logs) when the
18
87
  * package is not installed.
@@ -21,7 +90,7 @@ async function loadGaiaUiModule(logger, bases) {
21
90
  let resolved;
22
91
  for (const base of bases) {
23
92
  try {
24
- resolved = createRequire(base).resolve('@gaia-ai/plugin-gaia-ui');
93
+ resolved = createRequire(base).resolve('@gaia-ai/addon-gaia-ui');
25
94
  break;
26
95
  }
27
96
  catch {
@@ -29,57 +98,116 @@ async function loadGaiaUiModule(logger, bases) {
29
98
  }
30
99
  }
31
100
  if (resolved === undefined) {
32
- logger.error({}, 'gaia ui: could not resolve @gaia-ai/plugin-gaia-ui. ' +
101
+ logger.error({}, 'gaia ui: could not resolve @gaia-ai/addon-gaia-ui. ' +
33
102
  'Install it, or set $GAIA_UI_PLUGIN to a renderer plugin path.');
34
103
  return undefined;
35
104
  }
36
105
  return (await import(pathToFileURL(resolved).href));
37
106
  }
38
107
  /**
39
- * `gaia ui` — launch the home-rooted interactive terminal UI. Reads the
40
- * user-global machine context + the conductor registry (NOT a project engine
41
- * config), resolves the connection from the home `gaia.config.js` scope, adapts
42
- * a herdr-backed agent host to the renderer's prompt-level service, and
43
- * delegates to `@gaia-ai/plugin-gaia-ui`'s `runGaiaUi`. `$GAIA_UI_PLUGIN`
44
- * overrides the renderer module (dev).
108
+ * `gaia ui` — launch the interactive terminal UI. Resolves the connection
109
+ * through core's `loadGaiaConfig` (explicit project walk-up home shipped
110
+ * fallback), roots the agents in the project it was started in, reads the
111
+ * machine context for identity + the agent binary, adapts a herdr-backed agent
112
+ * host to the renderer's prompt-level service, and delegates to
113
+ * `@gaia-ai/addon-gaia-ui`'s `runGaiaUi`. `--print-config` reports the
114
+ * resolution and exits without launching. `$GAIA_UI_PLUGIN` overrides the
115
+ * renderer module (dev).
45
116
  */
46
117
  export async function cmdUi(deps = {}) {
47
118
  const logger = loggerFor(homedir());
48
119
  const host = deps.host ?? {
49
120
  resolveBases: [`${process.cwd()}/`, import.meta.url],
50
121
  };
51
- const mcPath = machineContextPath();
52
- const machine = deps.machine ?? (await readMachineContext(mcPath).catch(() => ({})));
53
- // Which connection config `gaia ui` reads: a user-authored
54
- // `~/.gaia/gaia.config.js` wins; otherwise the shipped machine-context
55
- // fallback. The test seam `homeConfigPath` forces a path (fallback branch).
56
- const userHomeConfig = join(homedir(), '.gaia', 'gaia.config.js');
57
- const resolved = deps.homeConfigPath
58
- ? { path: deps.homeConfigPath, fallback: true }
59
- : resolveUiConfigPath(userHomeConfig, homeFallbackGaiaConfigPath());
60
- let connection;
61
- if (resolved.fallback) {
62
- try {
63
- connection = buildHomeConnection(machine);
64
- }
65
- catch (err) {
66
- logger.error({}, err instanceof Error ? err.message : String(err));
67
- process.exitCode = 1;
68
- return;
122
+ const cwd = deps.cwd ?? process.cwd();
123
+ // User-facing CLI errors go to stderr, not the log sink: `createLogger` writes
124
+ // to `$HOME/log.txt` whenever stdout is not a TTY, and `gaia ui GAIA-1 | cat`
125
+ // must still SAY why it failed. Spec Decision 5: "a stderr line and exit 1".
126
+ const fail = (message) => {
127
+ (deps.stderr ?? ((line) => process.stderr.write(line)))(`${message}\n`);
128
+ process.exitCode = 1;
129
+ };
130
+ // GAIA-223: build the deep-link route FIRST — argv parsing, the conflict rule
131
+ // and the whole `--here` ladder are offline, so every one of their failures
132
+ // costs no config load and no network round-trip, and never reaches the
133
+ // launcher (AC-1/AC-2).
134
+ let initialRoute;
135
+ try {
136
+ const parsed = parseTarget({
137
+ target: deps.target,
138
+ ticket: deps.ticket,
139
+ project: deps.project,
140
+ run: deps.run,
141
+ here: deps.here,
142
+ view: deps.view,
143
+ });
144
+ if (parsed?.kind === 'route') {
145
+ initialRoute = parsed.route;
69
146
  }
70
- if (!process.env.GAIA_MACHINE_CONTEXT) {
71
- process.env.GAIA_MACHINE_CONTEXT = mcPath;
147
+ else if (parsed?.kind === 'here') {
148
+ initialRoute = await resolveHere({
149
+ cwd,
150
+ ...(parsed.view !== undefined ? { view: parsed.view } : {}),
151
+ readBranch: deps.readBranch ?? readCurrentBranch,
152
+ readRemoteUrl: deps.readRemoteUrl ?? readOriginRemoteUrl,
153
+ });
72
154
  }
73
155
  }
74
- else {
75
- connection = { baseUrl: '', authProfile: 'session', authPlugins: [] };
156
+ catch (err) {
157
+ fail(err instanceof Error ? err.message : String(err));
158
+ return;
76
159
  }
77
- connection.configPath = resolved.path;
160
+ // Read only AFTER the route: the whole grammar + ladder is offline, so a
161
+ // parse/conflict/no-remote failure must not even touch the registry file.
78
162
  const entries = deps.registryEntries ?? (await listRegisteredConductors());
79
- const project = resolveDefaultProject(entries);
80
- const projectEntry = entries.find((e) => e.project === project);
81
- const cwd = projectEntry?.path ?? homedir();
82
- const ownConductorMachineId = projectEntry?.id;
163
+ const mcPath = machineContextPath();
164
+ // The machine context still supplies identity (`user_id`) + the agent binary;
165
+ // it is NOT the connection any more — core resolves that.
166
+ const machine = deps.machine ?? (await readMachineContext(mcPath).catch(() => ({})));
167
+ // The ONE precedence rule, core's, the same one `gaia dropsh` uses (AC-1/2/3/
168
+ // 4/5/6). A legacy `conductor.config.js` carrying site/plugins is accepted on
169
+ // exactly core's terms — there is no back-compat branch here.
170
+ const load = deps.loadConnection ?? loadGaiaConfig;
171
+ let resolved;
172
+ try {
173
+ resolved = await load(host, {
174
+ cwd,
175
+ explicit: resolveExplicitConfig(deps.explicitConfig),
176
+ });
177
+ }
178
+ catch (err) {
179
+ logger.error({}, err instanceof Error ? err.message : String(err));
180
+ process.exitCode = 1;
181
+ return;
182
+ }
183
+ const connection = {
184
+ baseUrl: resolved.site.base_url,
185
+ jsonapiPrefix: resolved.site.jsonapi_prefix,
186
+ authPlugins: resolved.plugins,
187
+ configPath: resolved.config_path,
188
+ authProfile: 'session',
189
+ };
190
+ const rooting = resolveProjectRooting(cwd, entries, homedir());
191
+ const ownConductorMachineId = rooting.ownConductorMachineId;
192
+ const project = rooting.project;
193
+ // AC-7, always on: the resolution lands in the log even when the TUI takes
194
+ // over the screen a moment later.
195
+ logger.info({
196
+ config_path: resolved.config_path,
197
+ source: resolved.source,
198
+ fallback: resolved.fallback,
199
+ legacy: resolved.legacy,
200
+ base_url: resolved.site.base_url,
201
+ cwd: rooting.cwd,
202
+ project,
203
+ }, 'gaia ui: resolved connection');
204
+ // AC-7, the human surface: print and exit 0 WITHOUT booting the TUI, so a
205
+ // wrong-control-plane situation is diagnosable at a prompt.
206
+ if (deps.printConfig) {
207
+ const write = deps.stdout ?? ((s) => process.stdout.write(s));
208
+ write(formatConnectionReport(resolved, rooting));
209
+ return;
210
+ }
83
211
  const agentCommand = resolveAgentCommand(machine);
84
212
  const agentHost = deps.agentHost ?? herdrAgentHost((args) => exec('herdr', args));
85
213
  const agents = {
@@ -122,18 +250,38 @@ export async function cmdUi(deps = {}) {
122
250
  }
123
251
  run = uiMod.runGaiaUi;
124
252
  }
125
- await run({
126
- connection,
127
- cwd,
128
- ...(project !== undefined ? { project } : {}),
129
- agents,
130
- ...(machine.user_id ? { currentUser: machine.user_id } : {}),
131
- ...(ownConductorMachineId ? { ownConductorMachineId } : {}),
132
- buildProgram: (o) => buildDropshProgram({
133
- plugins: o.plugins,
134
- }),
135
- ...(renderer ? { renderer } : {}),
136
- });
253
+ // The launcher (and, through it, the renderer's route resolution — Decision 5)
254
+ // may reject BEFORE the TUI takes the terminal; `await run(...)` was unguarded,
255
+ // so that surfaced as an unhandled rejection. Catch it into exit 1 + a line.
256
+ try {
257
+ await run({
258
+ connection,
259
+ cwd: rooting.cwd,
260
+ ...(project !== undefined ? { project } : {}),
261
+ agents,
262
+ ...(machine.user_id ? { currentUser: machine.user_id } : {}),
263
+ ...(ownConductorMachineId ? { ownConductorMachineId } : {}),
264
+ // The plugins are real constructed `DropSHPlugin[]` now (core built them),
265
+ // matching dropsh's declared contract — F3's substance, a descriptor handed
266
+ // to an API expecting instances, is gone. The two casts that remain are
267
+ // structural, not unsound: `RunGaiaUiOptions` is a local MIRROR of the
268
+ // dynamically-resolved renderer's type (this package must not compile
269
+ // against it), so its `plugins` is `unknown[]` and its program type is the
270
+ // narrow `DropshProgram` above. Both casts only re-tell TypeScript what the
271
+ // mirror deliberately forgot.
272
+ // dropsh uses the plugins for renderers + `registerCommands` only; auth
273
+ // still comes from its own `$DROPSH_CONFIG` reload of the very file we
274
+ // resolved, so nothing is registered twice.
275
+ buildProgram: (o) => buildDropshProgram({
276
+ plugins: o.plugins,
277
+ }),
278
+ ...(renderer ? { renderer } : {}),
279
+ ...(initialRoute ? { initialRoute } : {}),
280
+ });
281
+ }
282
+ catch (err) {
283
+ fail(err instanceof Error ? err.message : String(err));
284
+ }
137
285
  }
138
286
  /** The `ui` command plugin the host mounts (`gaia ui`). */
139
287
  const uiCommandPlugin = {
@@ -144,8 +292,46 @@ const uiCommandPlugin = {
144
292
  program
145
293
  .command('ui')
146
294
  .description('interactive terminal UI: project dashboard, ticket list, ticket detail')
147
- .action(async () => {
148
- await cmdUi({ host });
295
+ .argument('[target]', 'deep-link target: GAIA-221 · ticket:<id|uuid> · project:<name|uuid> · run:<uuid>')
296
+ .option('--ticket <id>', 'open a ticket by identifier or uuid')
297
+ .option('--project <id>', 'open a project by name or uuid')
298
+ .option('--run <uuid>', "open a run's owning ticket, on its Runs tab")
299
+ .option('--here', "derive the target from the cwd: the branch's ticket, else this repo's project")
300
+ .option('--view <mode>', `how a ticket renders: ${UI_VIEW_MODES.join(' | ')}`)
301
+ .option('--config <path>', 'connection config path; wins over the project and home configs')
302
+ .option('--print-config', 'print the resolved connection config and exit, without launching the UI')
303
+ .addHelpText('after', `
304
+ Targets (at most ONE of the positional, --ticket, --project, --run, --here):
305
+ gaia ui the project dashboard
306
+ gaia ui GAIA-221 a ticket by identifier (case-insensitive)
307
+ gaia ui ticket:GAIA-221 the same, said explicitly
308
+ gaia ui ticket:<uuid> a ticket by uuid
309
+ gaia ui project:gaia a project by name (case-sensitive label)
310
+ gaia ui run:<uuid> that run's owning ticket, Runs tab active
311
+ gaia ui --ticket GAIA-221 the script-friendly flag form
312
+ gaia ui --here this branch's ticket (feat/gaia-223-… → GAIA-223),
313
+ else this repo's project
314
+
315
+ Modifier (orthogonal — never conflicts with a target):
316
+ gaia ui GAIA-221 --view teaser a compact block instead of the tabbed detail
317
+ gaia ui GAIA-221 --view board_card the board-card field set
318
+
319
+ Two target sources at once exit non-zero naming both. A bare uuid is ambiguous
320
+ (prefix it); a bare number is not an identifier. --view applies to ticket
321
+ targets only. Full reference: gaia-cli/ui/README.md
322
+ `)
323
+ .action(async (target, o) => {
324
+ await cmdUi({
325
+ host,
326
+ ...(target !== undefined ? { target } : {}),
327
+ ...(o.ticket !== undefined ? { ticket: o.ticket } : {}),
328
+ ...(o.project !== undefined ? { project: o.project } : {}),
329
+ ...(o.run !== undefined ? { run: o.run } : {}),
330
+ ...(o.here !== undefined ? { here: o.here } : {}),
331
+ ...(o.view !== undefined ? { view: o.view } : {}),
332
+ ...(o.config !== undefined ? { explicitConfig: o.config } : {}),
333
+ ...(o.printConfig ? { printConfig: true } : {}),
334
+ });
149
335
  });
150
336
  },
151
337
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gaia-ai/ui",
3
- "version": "0.6.0",
4
- "description": "GAIA home-rooted cockpit: the `gaia ui` command plugin (renderer resolved dynamically).",
3
+ "version": "0.6.2",
4
+ "description": "GAIA project-first cockpit: the `gaia ui` command plugin (renderer resolved dynamically).",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "engines": {
@@ -23,11 +23,11 @@
23
23
  "repository": {
24
24
  "type": "git",
25
25
  "url": "git+https://git.key-tec.de/keytec/gaia.git",
26
- "directory": "conductor/ui"
26
+ "directory": "gaia-cli/ui"
27
27
  },
28
28
  "dependencies": {
29
- "@gaia-ai/core": "^0.6.0",
30
- "@gaia-ai/plugin-herdr": "^0.6.0",
29
+ "@gaia-ai/core": "^0.6.2",
30
+ "@gaia-ai/addon-herdr": "^0.6.2",
31
31
  "commander": "^12.1.0",
32
32
  "dropsh": "^0.5.8"
33
33
  }