Skip to content

Commit 5af2afb

Browse files
committed
fix(qwen): address follow-up review — QWEN_HOME env, native hooks, MCP paths, canonical enablement
1. [P1] Read process.env.QWEN_HOME as fallback in both session analyzer resolveScope and customize provider defaultQwenHome. 2. [P1] Parse settings.json hooks in native Qwen shape: object keyed by event name → array of definitions → nested hooks array. Supports both command and http hook types. 3. [P1] User MCP now reads from <QWEN_HOME>/settings.json mcpServers (not ~/.mcp.json). Project MCP reads from <workspace>/.mcp.json (not <workspace>/.qwen/.mcp.json). Preserve url/httpUrl transport metadata. 4. [P2] Extension enablement now evaluates overrides against both lexical and canonical (realpathSync.native) workspace paths, matching native Qwen behavior for symlinked workspaces. Also: agent-customize --help adds qwen/--qwen-home; reference doc updated for native MCP paths and sanitizeCwd slug algorithm; rebased onto current main. 866 tests pass; pack:verify passes.
1 parent 038be3a commit 5af2afb

5 files changed

Lines changed: 83 additions & 54 deletions

File tree

references/agent-customize/platforms/qwen.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,18 @@ pointer to the real plugin root.
6565
## External Context
6666

6767
Use MCP when Qwen Code needs context or actions outside the repository. MCP
68-
configuration lives in `~/.mcp.json` (user) or `<project>/.mcp.json` (project).
69-
Start with one or two MCP tools that remove a real repeated manual step.
68+
servers are configured in `~/.qwen/settings.json` under `mcpServers` (user) or
69+
`<project>/.mcp.json` (project). Project-level `.qwen/settings.json` can also
70+
carry `mcpServers`. Start with one or two MCP tools that remove a real repeated
71+
manual step.
7072

7173
## Session Controls
7274

7375
Keep one Qwen Code session per coherent unit of work. Session transcripts are
7476
recorded as JSONL under `~/.qwen/projects/<workspace-slug>/chats/`. The slug
75-
replaces path separators (and `.`/`_`) with `-`. Use worktrees when concurrent
77+
replaces every non-alphanumeric character with `-` (matching Qwen's native
78+
`sanitizeCwd`; on Windows the path is lowercased first). Use worktrees when
79+
concurrent
7680
sessions could edit the same files. Use subagents for bounded exploration,
7781
testing, or independent review.
7882

scripts/agent-customize/cli.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import { collectAgentCustomizeInventory, filterManageItems, groupManageItems } f
55

66
function usage() {
77
return [
8-
"Usage: better-harness agent-customize [inventory|manage] --provider <cursor|qoder|codex|claude> [--workspace <path>]",
8+
"Usage: better-harness agent-customize [inventory|manage] --provider <cursor|qoder|codex|claude|qwen> [--workspace <path>]",
99
" better-harness agent-customize manage --provider <provider> [--tab <tab>] [--query <text>] [--scope <scope>] [--group-by <key>]",
1010
"",
1111
"Collect configured agent-customize inventory for one provider as JSON.",
1212
"Provider home overrides: --cursor-home, --qoder-home, --codex-home, --claude-home,",
13-
"--claude-state, --codex-app-path, --qoder-shared-client-cache-root.",
13+
"--qwen-home, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.",
1414
"",
1515
].join("\n");
1616
}

scripts/agent-customize/providers/qwen.mjs

Lines changed: 63 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { realpathSync } from "node:fs";
12
import os from "node:os";
23
import path from "node:path";
34

@@ -32,7 +33,7 @@ const QWEN_EXTENSION_INSTALL_FILE = ".qwen-extension-install.json";
3233
const QWEN_EXTENSION_ENABLEMENT_FILE = "extension-enablement.json";
3334

3435
function defaultQwenHome() {
35-
return path.join(os.homedir(), ".qwen");
36+
return process.env.QWEN_HOME ?? path.join(os.homedir(), ".qwen");
3637
}
3738

3839
function ensureLeadingAndTrailingSlash(dirPath) {
@@ -60,14 +61,44 @@ function isExtensionEnabled(enablementConfig, extensionName, workspace) {
6061
let enabled = true;
6162
const allOverrides = extensionConfig?.overrides ?? [];
6263
const lexicalPath = ensureLeadingAndTrailingSlash(workspace);
64+
let canonicalPath = lexicalPath;
65+
try { canonicalPath = ensureLeadingAndTrailingSlash(realpathSync.native(path.resolve(workspace))); } catch {}
6366
for (const rule of allOverrides) {
64-
if (overrideMatchesPath(rule, lexicalPath)) {
67+
if (overrideMatchesPath(rule, lexicalPath) || overrideMatchesPath(rule, canonicalPath)) {
6568
enabled = !rule.startsWith("!");
6669
}
6770
}
6871
return enabled;
6972
}
7073

74+
function flattenSettingsHooks(settingsHooks) {
75+
if (!settingsHooks || typeof settingsHooks !== "object" || Array.isArray(settingsHooks)) return [];
76+
const items = [];
77+
for (const [, definitions] of Object.entries(settingsHooks)) {
78+
if (!Array.isArray(definitions)) continue;
79+
for (const def of definitions) {
80+
if (!Array.isArray(def?.hooks)) continue;
81+
for (const hook of def.hooks) {
82+
if (hook?.command) items.push({ command: hook.command, type: hook.type ?? "command" });
83+
else if (hook?.url) items.push({ command: hook.url, type: "http" });
84+
}
85+
}
86+
}
87+
return items;
88+
}
89+
90+
function normalizeSettingsMcp(name, config, scope, sourceLabel, evidencePath, rootForEvidence) {
91+
return {
92+
name,
93+
scope,
94+
sourceLabel,
95+
command: config.command ?? null,
96+
args: config.args ?? [],
97+
url: config.url ?? config.httpUrl ?? null,
98+
evidence: evidence(evidencePath, rootForEvidence),
99+
};
100+
}
101+
71102
function qwenMarkdownRuleSource(workspace, sourceLabel, precedence = "after-provider-rules") {
72103
return {
73104
type: "file",
@@ -237,36 +268,23 @@ async function collectQwenPlugins(records, workspace) {
237268
}
238269

239270
async function collectQwenUserPrimitives(qwenHome) {
240-
const mcpPath = path.join(path.dirname(qwenHome), ".mcp.json");
241-
const mcps = await pathExists(mcpPath)
242-
? (await collectMcpFromConfig(mcpPath, "user", "User", qwenHome)) ?? []
243-
: (await collectMcpItems(qwenHome, "user", "User", qwenHome)) ?? [];
244-
const settings = (await readJson(path.join(qwenHome, "settings.json"))) ?? {};
271+
const settingsPath = path.join(qwenHome, "settings.json");
272+
const settings = (await readJson(settingsPath)) ?? {};
273+
const mcps = [];
245274
if (settings.mcpServers && typeof settings.mcpServers === "object") {
246275
for (const [name, config] of Object.entries(settings.mcpServers)) {
247-
if (!mcps.some((m) => m.name === name)) {
248-
mcps.push({
249-
name,
250-
scope: "user",
251-
sourceLabel: "User",
252-
command: config.command ?? null,
253-
args: config.args ?? [],
254-
evidence: evidence(path.join(qwenHome, "settings.json"), qwenHome),
255-
});
256-
}
276+
mcps.push(normalizeSettingsMcp(name, config, "user", "User", settingsPath, qwenHome));
257277
}
258278
}
259279
const hooks = await collectHookItems(qwenHome, "user", "User", qwenHome);
260-
if (Array.isArray(settings.hooks)) {
261-
for (const hook of settings.hooks) {
262-
if (hook?.command && !hooks.some((h) => h.command === hook.command)) {
263-
hooks.push({
264-
command: hook.command,
265-
scope: "user",
266-
sourceLabel: "User",
267-
evidence: evidence(path.join(qwenHome, "settings.json"), qwenHome),
268-
});
269-
}
280+
for (const hook of flattenSettingsHooks(settings.hooks)) {
281+
if (!hooks.some((h) => h.command === hook.command)) {
282+
hooks.push({
283+
command: hook.command,
284+
scope: "user",
285+
sourceLabel: "User",
286+
evidence: evidence(settingsPath, qwenHome),
287+
});
270288
}
271289
}
272290
return {
@@ -282,31 +300,30 @@ async function collectQwenUserPrimitives(qwenHome) {
282300
async function collectQwenWorkspacePrimitives(workspace) {
283301
const sourceLabel = await workspaceSourceLabel(workspace);
284302
const project = await collectWorkspaceRootPrimitives(path.join(workspace, ".qwen"), sourceLabel, workspace);
285-
const settings = (await readJson(path.join(workspace, ".qwen", "settings.json"))) ?? {};
303+
const projectMcpPath = path.join(workspace, ".mcp.json");
304+
if (await pathExists(projectMcpPath)) {
305+
const projectMcps = (await collectMcpFromConfig(projectMcpPath, "project", sourceLabel, workspace)) ?? [];
306+
for (const mcp of projectMcps) {
307+
if (!project.mcps.some((m) => m.name === mcp.name)) project.mcps.push(mcp);
308+
}
309+
}
310+
const settingsPath = path.join(workspace, ".qwen", "settings.json");
311+
const settings = (await readJson(settingsPath)) ?? {};
286312
if (settings.mcpServers && typeof settings.mcpServers === "object") {
287313
for (const [name, config] of Object.entries(settings.mcpServers)) {
288314
if (!project.mcps.some((m) => m.name === name)) {
289-
project.mcps.push({
290-
name,
291-
scope: "project",
292-
sourceLabel,
293-
command: config.command ?? null,
294-
args: config.args ?? [],
295-
evidence: evidence(path.join(workspace, ".qwen", "settings.json"), workspace),
296-
});
315+
project.mcps.push(normalizeSettingsMcp(name, config, "project", sourceLabel, settingsPath, workspace));
297316
}
298317
}
299318
}
300-
if (Array.isArray(settings.hooks)) {
301-
for (const hook of settings.hooks) {
302-
if (hook?.command && !project.hooks.some((h) => h.command === hook.command)) {
303-
project.hooks.push({
304-
command: hook.command,
305-
scope: "project",
306-
sourceLabel,
307-
evidence: evidence(path.join(workspace, ".qwen", "settings.json"), workspace),
308-
});
309-
}
319+
for (const hook of flattenSettingsHooks(settings.hooks)) {
320+
if (!project.hooks.some((h) => h.command === hook.command)) {
321+
project.hooks.push({
322+
command: hook.command,
323+
scope: "project",
324+
sourceLabel,
325+
evidence: evidence(settingsPath, workspace),
326+
});
310327
}
311328
}
312329
return {

scripts/session-analysis/platforms/qwen.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ export class QwenSessionAnalyzer extends SessionAnalyzer {
269269
const since = normalizeCliDate(options.since, false);
270270
const until = normalizeCliDate(options.until, true);
271271
const workspace = normalizeWorkspace(options.workspace);
272-
const home = path.resolve(expandHome(options.home ?? options.qwenHome ?? options["qwen-home"] ?? "~/.qwen"));
272+
const home = path.resolve(expandHome(options.home ?? options.qwenHome ?? options["qwen-home"] ?? process.env.QWEN_HOME ?? "~/.qwen"));
273273
// Qwen separates config home (QWEN_HOME / ~/.qwen) from runtime data
274274
// (QWEN_RUNTIME_DIR). Session transcripts live under the runtime dir.
275275
const runtimeDir = path.resolve(expandHome(

test/agent-customize.test.mjs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -767,10 +767,18 @@ async function makeQwenFixture() {
767767
],
768768
},
769769
});
770-
await writeJson(path.join(root, ".mcp.json"), {
770+
await writeJson(path.join(qwenHome, "settings.json"), {
771771
mcpServers: {
772772
localMcp: { command: "node", args: ["server.mjs"] },
773773
},
774+
hooks: {
775+
PreToolUse: [
776+
{
777+
matcher: "^Bash$",
778+
hooks: [{ type: "command", command: "~/.qwen/hooks/guard-bash.sh" }],
779+
},
780+
],
781+
},
774782
});
775783

776784
await writeText(
@@ -1594,7 +1602,7 @@ test("Qwen provider collects user and project MCPs, skills, hooks, and rules", a
15941602
filterManageItems(inventory, { tab: "hooks", scopeKind: "user" })
15951603
.map((item) => item.command)
15961604
.sort(),
1597-
["node hooks/audit-delivery.mjs", "~/.qwen/hooks/guard-prompt.sh"],
1605+
["node hooks/audit-delivery.mjs", "~/.qwen/hooks/guard-bash.sh", "~/.qwen/hooks/guard-prompt.sh"],
15981606
);
15991607
assert.deepEqual(
16001608
filterManageItems(inventory, { tab: "hooks", scopeKind: "project" }).map((item) => item.command),

0 commit comments

Comments
 (0)