@geraldmaron/construct 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * apps/chat/engine/tools/permission.mjs — the permission and sandbox gate for the
3
+ * owned loop's tools.
4
+ *
5
+ * Owning the loop means Construct, not a host, decides whether a tool runs. This
6
+ * gate reuses the host-agnostic decision vocabulary ADR-0040 defined
7
+ * (allow | allow_always | reject) and the sandbox levels from lib/chat/config.mjs
8
+ * (read-only, workspace-write, danger-full-access). Read-only tools (read, grep,
9
+ * glob) always pass; mutating tools (write, edit, shell) are gated by the sandbox
10
+ * level and the current permission mode, deferring to an interactive
11
+ * `requestPermission` handler only in `ask` mode. `allow_always` is sticky for the
12
+ * rest of the session so a user is not re-prompted per call.
13
+ *
14
+ * `allowOutside` (escape the workspace root) is granted only under
15
+ * danger-full-access; every other level keeps tools inside the workspace, which
16
+ * the primitives enforce independently.
17
+ */
18
+
19
+ export const READ_ONLY_TOOLS = new Set(['read', 'grep', 'glob']);
20
+ export const MUTATING_TOOLS = new Set(['write', 'edit', 'shell']);
21
+
22
+ export function createPermissionGate({
23
+ getSandbox = () => null,
24
+ getPermissionMode = () => 'allow_once',
25
+ requestPermission = null,
26
+ } = {}) {
27
+ let alwaysAllowed = false;
28
+
29
+ async function check(toolName, input = {}) {
30
+ const sandbox = getSandbox() || 'workspace-write';
31
+ const allowOutside = sandbox === 'danger-full-access';
32
+
33
+ if (READ_ONLY_TOOLS.has(toolName)) return { allowed: true, allowOutside };
34
+
35
+ if (sandbox === 'read-only') {
36
+ return { allowed: false, allowOutside, reason: 'sandbox is read-only; mutating tools are disabled (change with /set sandbox workspace-write)' };
37
+ }
38
+
39
+ if (alwaysAllowed) return { allowed: true, allowOutside };
40
+
41
+ const mode = getPermissionMode() || 'allow_once';
42
+ if (mode === 'reject') return { allowed: false, allowOutside, reason: 'permission mode is reject' };
43
+ if (mode === 'allow_always') { alwaysAllowed = true; return { allowed: true, allowOutside }; }
44
+ if (mode === 'allow_once') return { allowed: true, allowOutside };
45
+
46
+ // ask mode: defer to the interactive handler, mapping its decision back.
47
+ const decision = requestPermission ? await requestPermission({ tool: toolName, input }) : 'allow';
48
+ if (decision === 'reject') return { allowed: false, allowOutside, reason: 'denied by user' };
49
+ if (decision === 'allow_always') { alwaysAllowed = true; return { allowed: true, allowOutside }; }
50
+ return { allowed: true, allowOutside };
51
+ }
52
+
53
+ return { check, get alwaysAllowed() { return alwaysAllowed; } };
54
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * apps/chat/engine/tools/primitives.mjs — zero-dep executors for the owned loop's
3
+ * agent tools (read, write, edit, glob, grep, shell).
4
+ *
5
+ * These are the side-effecting primitives the loop calls; they are pure Node so
6
+ * they are fully testable in a tmpdir with no model, network, or SDK. Path safety
7
+ * is enforced here: every path resolves inside the workspace root unless the caller
8
+ * passes `allowOutside` (set only by the danger-full-access sandbox). Search tools
9
+ * walk the tree with a hard result/size cap and skip the usual heavy/ignored dirs
10
+ * so a grep never hangs the loop. Shell reuses the bounded worker runner
11
+ * (lib/worker/run.mjs) for the timeout + path policy already proven there.
12
+ *
13
+ * Every function returns a structured, JSON-serializable result (never throws for
14
+ * an ordinary "not found"/"no match" outcome) so the result maps cleanly onto a
15
+ * tool_update event and back to the model.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { randomUUID } from 'node:crypto';
21
+ import { runJob } from '../../../../lib/worker/run.mjs';
22
+
23
+ const IGNORED_DIRS = new Set(['node_modules', '.git', '.cx', 'dist', 'build', '.next', 'coverage', '.cache']);
24
+ const MAX_READ_BYTES = 256 * 1024;
25
+ const MAX_WALK_FILES = 20000;
26
+
27
+ function resolveInside(cwd, target, { allowOutside = false } = {}) {
28
+ const root = path.resolve(cwd);
29
+ const resolved = path.resolve(root, target || '.');
30
+ if (!allowOutside && resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
31
+ const err = new Error(`path '${target}' is outside the workspace; not permitted under the current sandbox`);
32
+ err.code = 'PATH_OUTSIDE_WORKSPACE';
33
+ throw err;
34
+ }
35
+ return resolved;
36
+ }
37
+
38
+ export function readFileTool({ cwd, path: target, maxBytes = MAX_READ_BYTES, allowOutside = false } = {}) {
39
+ const file = resolveInside(cwd, target, { allowOutside });
40
+ if (!fs.existsSync(file)) return { ok: false, error: `no such file: ${target}` };
41
+ const stat = fs.statSync(file);
42
+ if (stat.isDirectory()) return { ok: false, error: `${target} is a directory` };
43
+ const buf = fs.readFileSync(file);
44
+ const truncated = buf.length > maxBytes;
45
+ return { ok: true, path: target, bytes: stat.size, truncated, content: buf.slice(0, maxBytes).toString('utf8') };
46
+ }
47
+
48
+ export function writeFileTool({ cwd, path: target, content = '', allowOutside = false } = {}) {
49
+ const file = resolveInside(cwd, target, { allowOutside });
50
+ fs.mkdirSync(path.dirname(file), { recursive: true });
51
+ fs.writeFileSync(file, content, 'utf8');
52
+ return { ok: true, path: target, bytes: Buffer.byteLength(content, 'utf8') };
53
+ }
54
+
55
+ export function editFileTool({ cwd, path: target, oldString, newString = '', replaceAll = false, allowOutside = false } = {}) {
56
+ const file = resolveInside(cwd, target, { allowOutside });
57
+ if (!fs.existsSync(file)) return { ok: false, error: `no such file: ${target}` };
58
+ if (typeof oldString !== 'string' || oldString.length === 0) return { ok: false, error: 'oldString is required and must be non-empty' };
59
+ const before = fs.readFileSync(file, 'utf8');
60
+ const occurrences = before.split(oldString).length - 1;
61
+ if (occurrences === 0) return { ok: false, error: 'oldString not found in file' };
62
+ if (occurrences > 1 && !replaceAll) return { ok: false, error: `oldString is not unique (${occurrences} matches); pass replaceAll or add more context` };
63
+ const after = replaceAll ? before.split(oldString).join(newString) : before.replace(oldString, newString);
64
+ fs.writeFileSync(file, after, 'utf8');
65
+ return { ok: true, path: target, replacements: replaceAll ? occurrences : 1 };
66
+ }
67
+
68
+ // Bounded depth-first walk yielding workspace-relative file paths, skipping the
69
+ // ignored dirs. Caps the number of files visited so a pathological tree cannot
70
+ // stall the loop.
71
+
72
+ function* walkFiles(root) {
73
+ const stack = [root];
74
+ let visited = 0;
75
+ while (stack.length) {
76
+ const dir = stack.pop();
77
+ let entries;
78
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
79
+ for (const entry of entries) {
80
+ const full = path.join(dir, entry.name);
81
+ if (entry.isDirectory()) {
82
+ if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
83
+ stack.push(full);
84
+ } else if (entry.isFile()) {
85
+ if (++visited > MAX_WALK_FILES) return;
86
+ yield full;
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ // Translate a simple glob ("**/*.mjs", "src/*.ts", "lib/**") into a RegExp. Only
93
+ // the common operators are supported (no brace expansion), which covers what the
94
+ // loop needs without pulling a glob dependency.
95
+
96
+ function globToRegExp(pattern) {
97
+ let re = '';
98
+ for (let i = 0; i < pattern.length; i++) {
99
+ const c = pattern[i];
100
+ if (c === '*') {
101
+ if (pattern[i + 1] === '*') { re += '.*'; i++; if (pattern[i + 1] === '/') i++; }
102
+ else re += '[^/]*';
103
+ } else if (c === '?') re += '[^/]';
104
+ else if ('.+^${}()|[]\\'.includes(c)) re += `\\${c}`;
105
+ else re += c;
106
+ }
107
+ return new RegExp(`^${re}$`);
108
+ }
109
+
110
+ export function globTool({ cwd, pattern, limit = 200, allowOutside = false } = {}) {
111
+ const root = resolveInside(cwd, '.', { allowOutside });
112
+ if (!pattern) return { ok: false, error: 'pattern is required' };
113
+ const rx = globToRegExp(pattern);
114
+ const matches = [];
115
+ for (const file of walkFiles(root)) {
116
+ const rel = path.relative(root, file);
117
+ if (rx.test(rel)) {
118
+ matches.push(rel);
119
+ if (matches.length >= limit) break;
120
+ }
121
+ }
122
+ return { ok: true, pattern, matches, truncated: matches.length >= limit };
123
+ }
124
+
125
+ export function grepTool({ cwd, pattern, glob = null, limit = 200, caseInsensitive = false, allowOutside = false } = {}) {
126
+ const root = resolveInside(cwd, '.', { allowOutside });
127
+ if (!pattern) return { ok: false, error: 'pattern is required' };
128
+ let rx;
129
+ try { rx = new RegExp(pattern, caseInsensitive ? 'i' : ''); } catch (err) { return { ok: false, error: `invalid pattern: ${err.message}` }; }
130
+ const globRx = glob ? globToRegExp(glob) : null;
131
+ const matches = [];
132
+ for (const file of walkFiles(root)) {
133
+ const rel = path.relative(root, file);
134
+ if (globRx && !globRx.test(rel)) continue;
135
+ let text;
136
+ try {
137
+ const stat = fs.statSync(file);
138
+ if (stat.size > MAX_READ_BYTES) continue;
139
+ text = fs.readFileSync(file, 'utf8');
140
+ } catch { continue; }
141
+ if (text.includes('\u0000')) continue;
142
+ const lines = text.split('\n');
143
+ for (let i = 0; i < lines.length; i++) {
144
+ if (rx.test(lines[i])) {
145
+ matches.push({ file: rel, line: i + 1, text: lines[i].slice(0, 300) });
146
+ if (matches.length >= limit) return { ok: true, pattern, matches, truncated: true };
147
+ }
148
+ }
149
+ }
150
+ return { ok: true, pattern, matches, truncated: false };
151
+ }
152
+
153
+ export async function shellTool({ cwd, command, timeoutSeconds = 60, allowOutside = false } = {}) {
154
+ if (!command || typeof command !== 'string') return { ok: false, error: 'command is required' };
155
+ const root = path.resolve(cwd);
156
+ const result = await runJob({
157
+ rootDir: root,
158
+ job: {
159
+ jobId: `chat-${randomUUID()}`,
160
+ command,
161
+ timeoutSeconds,
162
+ workspaceRef: root,
163
+ allowedPaths: allowOutside ? [] : [root],
164
+ },
165
+ });
166
+ let stdout = '';
167
+ let stderr = '';
168
+ try { stdout = fs.readFileSync(result.stdoutPath, 'utf8'); } catch { /* no stdout */ }
169
+ try { stderr = fs.readFileSync(result.stderrPath, 'utf8'); } catch { /* no stderr */ }
170
+ return {
171
+ ok: result.status === 'passed',
172
+ status: result.status,
173
+ exitCode: result.exitCode,
174
+ durationMs: result.durationMs,
175
+ stdout: stdout.slice(0, MAX_READ_BYTES),
176
+ stderr: stderr.slice(0, MAX_READ_BYTES),
177
+ };
178
+ }
179
+
180
+ export const __test__ = { resolveInside, globToRegExp };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * apps/chat/engine/tools/registry.mjs — builds the owned loop's tool set as Vercel
3
+ * AI SDK tools, gated by the permission/sandbox policy.
4
+ *
5
+ * Each tool wraps a zero-dep primitive (primitives.mjs) behind the permission gate
6
+ * (permission.mjs): the gate runs first, and a denial returns a structured result
7
+ * the model can read rather than throwing, so the loop degrades gracefully instead
8
+ * of crashing on a blocked action. A single `construct_tool` bridges the loop to
9
+ * Construct's existing MCP tool surface via dispatchToolByName (mirroring the MCP
10
+ * `construct_call` meta-tool), so the loop reaches knowledge search, skills, and
11
+ * orchestration policy without re-declaring 60+ schemas here.
12
+ *
13
+ * `ai` and `zod` are imported lazily and only here, so the rest of the engine and
14
+ * all of its tests stay free of the optional dependencies.
15
+ */
16
+
17
+ import {
18
+ readFileTool, writeFileTool, editFileTool, globTool, grepTool, shellTool,
19
+ } from './primitives.mjs';
20
+ import { createPermissionGate } from './permission.mjs';
21
+
22
+ function denied(reason) {
23
+ return { ok: false, denied: true, error: reason };
24
+ }
25
+
26
+ export async function buildAgentTools({ env = process.env, cwd = process.cwd(), handlers = {}, only = null } = {}) {
27
+ const { tool } = await import('ai');
28
+ const { z } = await import('zod');
29
+
30
+ const gate = createPermissionGate({
31
+ getSandbox: handlers.getSandbox || (() => null),
32
+ getPermissionMode: handlers.getPermissionMode || (() => 'allow_once'),
33
+ requestPermission: handlers.requestPermission || null,
34
+ });
35
+
36
+ const defs = {
37
+ read: tool({
38
+ description: 'Read a UTF-8 text file from the workspace.',
39
+ inputSchema: z.object({ path: z.string().describe('workspace-relative file path') }),
40
+ execute: async ({ path: p }) => {
41
+ const verdict = await gate.check('read', { path: p });
42
+ if (!verdict.allowed) return denied(verdict.reason);
43
+ return readFileTool({ cwd, path: p, allowOutside: verdict.allowOutside });
44
+ },
45
+ }),
46
+ glob: tool({
47
+ description: 'Find files by glob pattern (supports * ** ?).',
48
+ inputSchema: z.object({ pattern: z.string(), limit: z.number().int().positive().max(1000).optional() }),
49
+ execute: async ({ pattern, limit }) => {
50
+ const verdict = await gate.check('glob', { pattern });
51
+ if (!verdict.allowed) return denied(verdict.reason);
52
+ return globTool({ cwd, pattern, limit, allowOutside: verdict.allowOutside });
53
+ },
54
+ }),
55
+ grep: tool({
56
+ description: 'Search file contents by regular expression, optionally filtered by a glob.',
57
+ inputSchema: z.object({
58
+ pattern: z.string(),
59
+ glob: z.string().optional(),
60
+ caseInsensitive: z.boolean().optional(),
61
+ limit: z.number().int().positive().max(1000).optional(),
62
+ }),
63
+ execute: async ({ pattern, glob, caseInsensitive, limit }) => {
64
+ const verdict = await gate.check('grep', { pattern });
65
+ if (!verdict.allowed) return denied(verdict.reason);
66
+ return grepTool({ cwd, pattern, glob, caseInsensitive, limit, allowOutside: verdict.allowOutside });
67
+ },
68
+ }),
69
+ write: tool({
70
+ description: 'Create or overwrite a workspace file with the given content.',
71
+ inputSchema: z.object({ path: z.string(), content: z.string() }),
72
+ execute: async ({ path: p, content }) => {
73
+ const verdict = await gate.check('write', { path: p });
74
+ if (!verdict.allowed) return denied(verdict.reason);
75
+ return writeFileTool({ cwd, path: p, content, allowOutside: verdict.allowOutside });
76
+ },
77
+ }),
78
+ edit: tool({
79
+ description: 'Replace an exact string in a workspace file. oldString must be unique unless replaceAll is set.',
80
+ inputSchema: z.object({
81
+ path: z.string(),
82
+ oldString: z.string(),
83
+ newString: z.string(),
84
+ replaceAll: z.boolean().optional(),
85
+ }),
86
+ execute: async ({ path: p, oldString, newString, replaceAll }) => {
87
+ const verdict = await gate.check('edit', { path: p });
88
+ if (!verdict.allowed) return denied(verdict.reason);
89
+ return editFileTool({ cwd, path: p, oldString, newString, replaceAll, allowOutside: verdict.allowOutside });
90
+ },
91
+ }),
92
+ shell: tool({
93
+ description: 'Run a bounded shell command in the workspace with a timeout.',
94
+ inputSchema: z.object({ command: z.string(), timeoutSeconds: z.number().int().positive().max(600).optional() }),
95
+ execute: async ({ command, timeoutSeconds }) => {
96
+ const verdict = await gate.check('shell', { command });
97
+ if (!verdict.allowed) return denied(verdict.reason);
98
+ return shellTool({ cwd, command, timeoutSeconds, allowOutside: verdict.allowOutside });
99
+ },
100
+ }),
101
+ construct_tool: tool({
102
+ description: 'Call a Construct MCP tool by name (e.g. knowledge_search, search_skills, orchestration_policy). Returns the tool result.',
103
+ inputSchema: z.object({ name: z.string(), args: z.record(z.string(), z.any()).optional() }),
104
+ execute: async ({ name, args }) => {
105
+ try {
106
+ const { dispatchToolByName } = await import('../../../../lib/mcp/server.mjs');
107
+ const result = await dispatchToolByName(name, args || {});
108
+ return { ok: true, name, result };
109
+ } catch (err) {
110
+ return { ok: false, name, error: err?.message || String(err) };
111
+ }
112
+ },
113
+ }),
114
+ };
115
+
116
+ if (!Array.isArray(only) || only.length === 0) return defs;
117
+ const filtered = {};
118
+ for (const name of only) if (defs[name]) filtered[name] = defs[name];
119
+ return filtered;
120
+ }
121
+
122
+ export const AGENT_TOOL_NAMES = ['read', 'glob', 'grep', 'write', 'edit', 'shell', 'construct_tool'];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * apps/chat/engine/turn-controls.mjs — resolve one turn's execution controls from
3
+ * the compiled execution policy (construct-rv2x).
4
+ *
5
+ * The owned-loop engine (ai-sdk-agent.mjs) calls this once per turn to turn a
6
+ * capability profile plus the turn overlay into the concrete knobs streamText
7
+ * needs: the step cap, the output-token cap, caching eligibility, and the tool-
8
+ * group / schema budget, plus the continuation/compaction budget the session uses
9
+ * to decide when to compact context (construct-6zga.1.9). The capability profile
10
+ * is resolved from the active model id, so a mid-session model switch re-derives
11
+ * the envelope.
12
+ *
13
+ * Behavior preservation is the contract: this never throws and never returns a
14
+ * tighter envelope than today for a hosted-direct model. hosted-direct keeps the
15
+ * legacy step cap, the full tool set, and unbounded output; every other class
16
+ * enforces its compiled budget. An explicit CX_CHAT_MAX_STEPS operator override
17
+ * wins over the policy's step cap. Any failure in policy resolution falls back to
18
+ * the legacy envelope (16 steps, full tool set, no cap, no caching) so a policy
19
+ * bug can never break a chat turn.
20
+ */
21
+
22
+ import { resolveExecutionCapabilityProfile } from '../../../lib/models/execution-capability-profile.mjs';
23
+ import { compilePolicyFromOverlay } from '../../../lib/models/execution-policy.mjs';
24
+ import { continuationBudgetFromPolicy } from '../../../lib/chat/context-continuation.mjs';
25
+
26
+ const LEGACY_STEP_CAP = 16;
27
+
28
+ const LEGACY_CONTROLS = Object.freeze({
29
+ policy: null,
30
+ iterations: LEGACY_STEP_CAP,
31
+ outputCap: null,
32
+ cacheEligible: false,
33
+ allowedToolGroups: null,
34
+ maxToolSchemas: Infinity,
35
+ degraded: false,
36
+ continuation: Object.freeze({ triggerTokens: null, triggerRatio: null }),
37
+ });
38
+
39
+ function legacyControls() {
40
+ return { ...LEGACY_CONTROLS };
41
+ }
42
+
43
+ export function resolveTurnControls({ model = null, turnOverlay = null, env = {} } = {}) {
44
+ try {
45
+ const profile = resolveExecutionCapabilityProfile({ model });
46
+ const toolFailure = turnOverlay?.toolFailure === true;
47
+ const policy = compilePolicyFromOverlay({ profile, overlay: turnOverlay, toolFailure });
48
+
49
+ const envSteps = Number(env?.CX_CHAT_MAX_STEPS);
50
+ const iterations = envSteps > 0 ? Math.floor(envSteps) : policy.tools.maxToolIterations;
51
+
52
+ // hosted-direct is the mandated default path: keep today's unbounded output
53
+ // until live-probe evidence supersedes the compatibility_fallback tier. Every
54
+ // other class enforces its compiled output budget.
55
+ const outputCap = policy.source.capabilityClass === 'hosted-direct' ? null : policy.output.outputTokenBudget;
56
+
57
+ return {
58
+ policy,
59
+ iterations,
60
+ outputCap,
61
+ cacheEligible: policy.caching.eligible === true,
62
+ allowedToolGroups: policy.tools.allowedToolGroups,
63
+ maxToolSchemas: policy.tools.maxToolSchemas,
64
+ degraded: policy.degradedMode === true,
65
+ continuation: continuationBudgetFromPolicy(policy),
66
+ };
67
+ } catch {
68
+ return legacyControls();
69
+ }
70
+ }
package/bin/construct CHANGED
@@ -653,6 +653,21 @@ async function cmdDoctor() {
653
653
  );
654
654
  }
655
655
 
656
+ // Mirror model tier overrides stranded in the pre-XDG legacy config forward
657
+ // before resolving tiers, so an install upgraded across the XDG move reports
658
+ // configured without a manual copy. Applied to process.env too so the check
659
+ // below sees the just-migrated values in the same run.
660
+ try {
661
+ const { migrateLegacyModelConfig } = await import('../lib/config/legacy-config-migration.mjs');
662
+ const migration = migrateLegacyModelConfig({ homeDir: HOME });
663
+ if (migration.performed) {
664
+ Object.assign(process.env, migration.migrated);
665
+ println(` fix: migrated ${Object.keys(migration.migrated).join(', ')} from ${migration.legacyPath} → ${migration.xdgPath}`);
666
+ }
667
+ } catch (err) {
668
+ add(`Models — legacy config migration failed: ${err.message}`, false, true);
669
+ }
670
+
656
671
  // Tier model selection. Construct ships with no default — at least
657
672
  // one tier must be configured (registry.json primary OR CX_MODEL_*
658
673
  // env override) before any LLM-backed workflow can run.
@@ -3773,7 +3788,15 @@ async function cmdOrchestrate(args) {
3773
3788
  }
3774
3789
 
3775
3790
  async function cmdModels(args) {
3776
- if (args[0] === 'resolve') {
3791
+ const envPath = getUserEnvPath(HOME);
3792
+ const sub = args[0] && !args[0].startsWith('-') ? args[0] : null;
3793
+ const KNOWN_SUBCOMMANDS = new Set(['list', 'set', 'free', 'reset', 'resolve']);
3794
+ if (sub && !KNOWN_SUBCOMMANDS.has(sub)) {
3795
+ errorln(`Unknown models subcommand: ${sub}. Run \`construct models --help\` for the supported set.`);
3796
+ process.exit(1);
3797
+ }
3798
+
3799
+ if (sub === 'resolve') {
3777
3800
  const flag = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : undefined; };
3778
3801
  const { resolveEmbeddedModel } = await import('../lib/embedded-contract/model-resolve.mjs');
3779
3802
  const { wrapContractResult } = await import('../lib/embedded-contract/envelope.mjs');
@@ -3790,21 +3813,37 @@ async function cmdModels(args) {
3790
3813
  println(JSON.stringify(envelope, null, 2));
3791
3814
  return;
3792
3815
  }
3816
+ // Legacy flag forms stay as compatibility aliases for the documented
3817
+ // subcommands. Each emits a one-line deprecation notice on stderr so stdout
3818
+ // stays clean while operators migrate to `models set|reset|free`.
3793
3819
  const tier = args.find((arg) => arg.startsWith('--tier='))?.split('=')[1] ?? '';
3794
- const setModel = args.find((arg) => arg.startsWith('--set='))?.split('=')[1] ?? '';
3795
- const envPath = getUserEnvPath(HOME);
3796
- if (args.includes('--reset')) {
3820
+ const modelFlag = args.find((arg) => arg.startsWith('--model='))?.split('=')[1];
3821
+ const legacySetFlag = args.find((arg) => arg.startsWith('--set='))?.split('=')[1];
3822
+ const setModel = modelFlag ?? legacySetFlag ?? '';
3823
+
3824
+ if (sub === 'reset' || args.includes('--reset')) {
3825
+ if (sub !== 'reset') errorln('Note: `construct models --reset` is a deprecated alias for `construct models reset`.');
3797
3826
  resetEnv(envPath);
3798
- println('Removed CX_MODEL_* overrides from ~/.construct/config.env.');
3827
+ println(`Removed CX_MODEL_* overrides from ${envPath}.`);
3799
3828
  await cmdSync([]);
3800
3829
  return;
3801
3830
  }
3802
- if (tier && setModel) {
3831
+ if (sub === 'set' || (sub === null && tier && setModel)) {
3832
+ if (!tier || !setModel) {
3833
+ errorln('Usage: construct models set --tier=<reasoning|standard|fast> --model=<provider/model-id>');
3834
+ process.exit(1);
3835
+ }
3836
+ if (modelFlag === undefined && legacySetFlag !== undefined) {
3837
+ errorln('Note: `--set=<model>` is a deprecated alias for `--model=<model>`.');
3838
+ }
3839
+ if (sub === null) {
3840
+ errorln('Note: `construct models --tier=… --set=…` is a deprecated alias for `construct models set --tier=… --model=…`.');
3841
+ }
3803
3842
  const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
3804
3843
  const preferFree = args.includes('--prefer-free');
3805
3844
  const preferFreeSameFamily = args.includes('--prefer-free-same-family');
3806
3845
  const inferred = setModelWithTierInference(envPath, tier, setModel, registry.models ?? {}, { preferFree, preferFreeSameFamily });
3807
- println(`Set ${tier} -> ${setModel} in ~/.construct/config.env`);
3846
+ println(`Set ${tier} -> ${setModel} in ${envPath}`);
3808
3847
  if (preferFreeSameFamily) println('Prefer-free-same-family mode: enabled');
3809
3848
  if (preferFree) println('Prefer-free mode: enabled');
3810
3849
  println(`Resolved tier set:`);
@@ -3814,7 +3853,8 @@ async function cmdModels(args) {
3814
3853
  await cmdSync([]);
3815
3854
  return;
3816
3855
  }
3817
- if (args.includes('--poll')) {
3856
+ if (sub === 'free' || args.includes('--poll')) {
3857
+ if (sub !== 'free') errorln('Note: `construct models --poll` is a deprecated alias for `construct models free`.');
3818
3858
  const apiKey = process.env.OPENROUTER_API_KEY || readOpenRouterApiKeyFromOpenCodeConfig();
3819
3859
  if (!apiKey) {
3820
3860
  errorln('Error: no OpenRouter API key found in OPENROUTER_API_KEY or OpenCode config.');
@@ -3841,7 +3881,7 @@ async function cmdModels(args) {
3841
3881
  if (id) selections[currentTier] = id;
3842
3882
  }
3843
3883
  applyToEnv(envPath, selections);
3844
- println('Written to ~/.construct/config.env. Running construct sync...');
3884
+ println(`Written to ${envPath}. Running construct sync...`);
3845
3885
  await cmdSync([]);
3846
3886
  return;
3847
3887
  }
@@ -3878,12 +3918,12 @@ async function cmdModels(args) {
3878
3918
  for (const [tier, model] of Object.entries(selections)) {
3879
3919
  println(` ${tier.padEnd(11)} ${model}`);
3880
3920
  }
3881
- println('Written to ~/.construct/config.env. Running construct sync...');
3921
+ println(`Written to ${envPath}. Running construct sync...`);
3882
3922
  setCheapestProviderPreference(envPath, true);
3883
3923
  await cmdSync([]);
3884
3924
  return;
3885
3925
  }
3886
- if (args.includes('--list') || args[0] === 'list') {
3926
+ if (args.includes('--list') || sub === 'list') {
3887
3927
  const { getProviderModelCatalog } = await import('../lib/model-router.mjs');
3888
3928
  const { listChatModels } = await import('../apps/chat/engine/models.mjs');
3889
3929
  const catalog = getProviderModelCatalog({ env: process.env, cwd: process.cwd() });
@@ -3913,7 +3953,7 @@ async function cmdModels(args) {
3913
3953
  println('No tier has a model selected. Construct ships with no default.');
3914
3954
  println('Pick one of:');
3915
3955
  println(' • Dashboard → Models page (single source of truth)');
3916
- println(' • construct models --set=<provider/model-id> --tier=reasoning|standard|fast');
3956
+ println(' • construct models set --tier=<reasoning|standard|fast> --model=<provider/model-id>');
3917
3957
  println(' • construct models --apply Poll OpenRouter free catalog and seed all tiers');
3918
3958
  }
3919
3959
  }
@@ -429,14 +429,12 @@ export const CLI_COMMANDS = [
429
429
  category: 'Models & Integrations',
430
430
  core: false,
431
431
  description: 'Show or update model tier assignments',
432
- usage: 'construct models <list|set|free|reset|usage|cost|resolve>',
432
+ usage: 'construct models <list|set|free|reset|resolve>',
433
433
  subcommands: [
434
434
  { name: 'list', desc: 'Show current tier assignments' },
435
435
  { name: 'set --tier=<reasoning|standard|fast> --model=<model>', desc: 'Set a model for a tier' },
436
436
  { name: 'free', desc: 'List available free models' },
437
437
  { name: 'reset', desc: 'Reset all tier assignments' },
438
- { name: 'usage', desc: 'Show token usage per tier' },
439
- { name: 'cost', desc: 'Show cost breakdown' },
440
438
  { name: 'resolve --json', desc: 'Resolve the model for an embedded workflow given host context' },
441
439
  ],
442
440
  },
@@ -0,0 +1,59 @@
1
+ /**
2
+ * lib/config/legacy-config-migration.mjs — one-time forward migration of model
3
+ * tier overrides from the pre-XDG legacy config into the active XDG config.
4
+ *
5
+ * The config move to $XDG_CONFIG_HOME/construct (see lib/config/xdg.mjs) is a
6
+ * clean break with no legacy read, so any install that set CX_MODEL_* before
7
+ * the move keeps those values stranded in ~/.construct/config.env. Resolution
8
+ * reads only the XDG path, so doctor then reports "no tier configured" until
9
+ * the user hand-copies the keys. This mirrors the stranded tier overrides
10
+ * forward, and never overwrites a value the XDG config already defines.
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+
17
+ import { parseEnvFile, writeEnvValues } from '../env-config.mjs';
18
+ import { configDir } from './xdg.mjs';
19
+
20
+ // Only model tier overrides migrate. API keys and other secrets are
21
+ // intentionally out of scope — they are re-established through the credential
22
+ // bootstrap, not copied between paths.
23
+
24
+ const MIGRATABLE_KEY = /^(?:CX|CONSTRUCT)_MODEL_[A-Z0-9_]+$/;
25
+
26
+ export function legacyConfigPath(homeDir = os.homedir()) {
27
+ return path.join(homeDir, '.construct', 'config.env');
28
+ }
29
+
30
+ /**
31
+ * Migrate stranded CX_MODEL_ and CONSTRUCT_MODEL_ tier overrides from the
32
+ * legacy config into the XDG config. Returns the keys that were written so
33
+ * callers can surface a one-time message and refresh their in-process env.
34
+ */
35
+ export function migrateLegacyModelConfig({ homeDir = os.homedir(), env = process.env } = {}) {
36
+ const legacyPath = legacyConfigPath(homeDir);
37
+ const xdgPath = path.join(configDir(homeDir, env), 'config.env');
38
+ const result = { performed: false, migrated: {}, legacyPath, xdgPath };
39
+
40
+ if (legacyPath === xdgPath || !fs.existsSync(legacyPath)) return result;
41
+
42
+ const legacyEnv = parseEnvFile(legacyPath);
43
+ const xdgEnv = parseEnvFile(xdgPath);
44
+
45
+ const migrated = {};
46
+ for (const [key, value] of Object.entries(legacyEnv)) {
47
+ if (!MIGRATABLE_KEY.test(key)) continue;
48
+ if (value === '' || value === undefined) continue;
49
+ if (xdgEnv[key] !== undefined && xdgEnv[key] !== '') continue;
50
+ migrated[key] = value;
51
+ }
52
+
53
+ if (Object.keys(migrated).length === 0) return result;
54
+
55
+ writeEnvValues(xdgPath, migrated);
56
+ result.performed = true;
57
+ result.migrated = migrated;
58
+ return result;
59
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * lib/runtime-env.mjs — Construct runtime environment with credential bootstrap.
3
3
  *
4
- * prepareConstructEnv merges ~/.construct/config.env into the process environment.
4
+ * prepareConstructEnv merges ~/.config/construct/config.env into the process environment.
5
5
  * Credential auto-link from 1Password runs only when autoLink:true (setup-credentials).
6
6
  */
7
7