@lumoai/cli 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/assets/skill.md +189 -16
  2. package/dist/cli/src/commands/auth-login.js +4 -3
  3. package/dist/cli/src/commands/auth-logout.js +2 -1
  4. package/dist/cli/src/commands/doc-bind.js +3 -3
  5. package/dist/cli/src/commands/doc-create.js +5 -5
  6. package/dist/cli/src/commands/doc-delete.js +4 -4
  7. package/dist/cli/src/commands/doc-import-gdoc.js +82 -0
  8. package/dist/cli/src/commands/doc-list.js +7 -7
  9. package/dist/cli/src/commands/doc-move.js +8 -8
  10. package/dist/cli/src/commands/doc-share-list.js +11 -8
  11. package/dist/cli/src/commands/doc-share.js +7 -5
  12. package/dist/cli/src/commands/doc-show.js +6 -6
  13. package/dist/cli/src/commands/doc-sync.js +44 -0
  14. package/dist/cli/src/commands/doc-unbind.js +4 -4
  15. package/dist/cli/src/commands/doc-unshare.js +9 -7
  16. package/dist/cli/src/commands/doc-update.js +5 -5
  17. package/dist/cli/src/commands/hook.js +2 -2
  18. package/dist/cli/src/commands/memory-project-add.js +19 -4
  19. package/dist/cli/src/commands/memory-project-list.js +1 -2
  20. package/dist/cli/src/commands/memory-promote.js +3 -3
  21. package/dist/cli/src/commands/memory-rm.js +1 -2
  22. package/dist/cli/src/commands/memory-task-add.js +19 -4
  23. package/dist/cli/src/commands/memory-task-list.js +1 -2
  24. package/dist/cli/src/commands/milestone-create.js +4 -4
  25. package/dist/cli/src/commands/milestone-delete.js +5 -5
  26. package/dist/cli/src/commands/milestone-list.js +3 -3
  27. package/dist/cli/src/commands/milestone-show.js +5 -5
  28. package/dist/cli/src/commands/milestone-update.js +6 -5
  29. package/dist/cli/src/commands/project-list.js +3 -3
  30. package/dist/cli/src/commands/session-attach.js +5 -5
  31. package/dist/cli/src/commands/session-detach.js +3 -3
  32. package/dist/cli/src/commands/session-status.js +3 -3
  33. package/dist/cli/src/commands/session-wrap.js +32 -0
  34. package/dist/cli/src/commands/setup.js +33 -7
  35. package/dist/cli/src/commands/sprint-add.js +3 -3
  36. package/dist/cli/src/commands/sprint-close.js +5 -5
  37. package/dist/cli/src/commands/sprint-create.js +4 -4
  38. package/dist/cli/src/commands/sprint-delete.js +5 -5
  39. package/dist/cli/src/commands/sprint-list.js +3 -3
  40. package/dist/cli/src/commands/sprint-remove.js +3 -3
  41. package/dist/cli/src/commands/sprint-show.js +4 -4
  42. package/dist/cli/src/commands/sprint-start.js +4 -4
  43. package/dist/cli/src/commands/sprint-summary.js +7 -7
  44. package/dist/cli/src/commands/sprint-update.js +6 -5
  45. package/dist/cli/src/commands/task-artifact-add.js +17 -5
  46. package/dist/cli/src/commands/task-artifact-list.js +4 -4
  47. package/dist/cli/src/commands/task-artifact-rm.js +4 -4
  48. package/dist/cli/src/commands/task-artifact-show.js +8 -8
  49. package/dist/cli/src/commands/task-artifact-update.js +5 -5
  50. package/dist/cli/src/commands/task-comment-list.js +111 -0
  51. package/dist/cli/src/commands/task-comment.js +3 -3
  52. package/dist/cli/src/commands/task-context.js +29 -12
  53. package/dist/cli/src/commands/task-create.js +7 -7
  54. package/dist/cli/src/commands/task-figma-add.js +3 -2
  55. package/dist/cli/src/commands/task-figma-context.js +61 -0
  56. package/dist/cli/src/commands/task-figma-list.js +3 -2
  57. package/dist/cli/src/commands/task-figma-refresh.js +4 -3
  58. package/dist/cli/src/commands/task-figma-rm.js +3 -2
  59. package/dist/cli/src/commands/task-list.js +1 -2
  60. package/dist/cli/src/commands/task-pr-show.js +66 -0
  61. package/dist/cli/src/commands/task-show.js +8 -7
  62. package/dist/cli/src/commands/task-slack-show.js +59 -0
  63. package/dist/cli/src/commands/task-update.js +7 -7
  64. package/dist/cli/src/commands/task-web-show.js +64 -0
  65. package/dist/cli/src/commands/whoami.js +4 -3
  66. package/dist/cli/src/commands/wrap/progress-comment-section.js +81 -0
  67. package/dist/cli/src/index.js +174 -102
  68. package/dist/cli/src/lib/agent.js +10 -1
  69. package/dist/cli/src/lib/api.js +81 -1
  70. package/dist/cli/src/lib/config.js +2 -1
  71. package/dist/cli/src/lib/doc-input.js +12 -1
  72. package/dist/cli/src/lib/editor.js +66 -0
  73. package/dist/cli/src/lib/figma-api.js +1 -1
  74. package/dist/cli/src/lib/format.js +3 -2
  75. package/dist/cli/src/lib/hook-runner.js +64 -19
  76. package/dist/cli/src/lib/hooks-template.js +52 -7
  77. package/dist/cli/src/lib/memory-content.js +4 -3
  78. package/dist/cli/src/lib/path-guard.js +125 -0
  79. package/dist/cli/src/lib/progress-comment-api.js +47 -0
  80. package/dist/cli/src/lib/resolve-doc-id.js +2 -1
  81. package/dist/cli/src/lib/resolve-member.js +2 -1
  82. package/dist/cli/src/lib/sanitize.js +17 -0
  83. package/dist/cli/src/lib/tag-resolver.js +2 -1
  84. package/dist/cli/src/lib/update-check.js +2 -2
  85. package/dist/cli/src/lib/wrap-panel.js +15 -0
  86. package/package.json +1 -1
@@ -6,6 +6,8 @@ exports.runHookWithBody = runHookWithBody;
6
6
  const config_1 = require("./config");
7
7
  const api_1 = require("./api");
8
8
  const hook_log_1 = require("./hook-log");
9
+ const sanitize_1 = require("./sanitize");
10
+ const agent_1 = require("./agent");
9
11
  /**
10
12
  * Hard timeout for the hook POST. On timeout the request is aborted,
11
13
  * logged, and `runHook` exits 0 — Claude Code is never blocked beyond
@@ -49,16 +51,40 @@ function readStdin() {
49
51
  }
50
52
  /**
51
53
  * Build the array of stdout lines to emit for a given hook path + response
52
- * body. Returns an empty array for any path other than 'session-start'.
54
+ * body. Returns an empty array for any path that emits nothing.
53
55
  *
54
56
  * For 'session-start' the array contains:
55
57
  * [0] plain-text bind/unbound status line (always present when taskBinding exists)
56
- * [1] (optional) hookSpecificOutput JSON when memorySection is a non-empty string
58
+ * [1] (optional) hookSpecificOutput JSON when there is any additionalContext
59
+ * the memory section and the PR-review-todos section, concatenated.
57
60
  *
58
- * The JSON on line [1] conforms to Claude Code's hookSpecificOutput envelope so
59
- * the runtime injects additionalContext into the conversation automatically.
61
+ * For 'pre-tool-use' the array contains:
62
+ * [0] (optional) a PreToolUse hookSpecificOutput JSON carrying the parallel-
63
+ * edit collision warning as additionalContext, when the server returned a
64
+ * `collisionWarning` (LUM-150 step ③). Empty otherwise.
65
+ *
66
+ * The JSON lines conform to Claude Code's hookSpecificOutput envelope so the
67
+ * runtime injects additionalContext into the conversation automatically.
60
68
  */
61
69
  function formatHookStdoutLines(path, responseBody) {
70
+ if (path === 'pre-tool-use') {
71
+ if (responseBody == null || typeof responseBody !== 'object')
72
+ return [];
73
+ const warning = responseBody
74
+ .collisionWarning;
75
+ if (typeof warning !== 'string' || warning === '')
76
+ return [];
77
+ return [
78
+ JSON.stringify({
79
+ hookSpecificOutput: {
80
+ hookEventName: 'PreToolUse',
81
+ // Server-built text routed back to stdout — sanitize untrusted free
82
+ // text before Claude Code consumes it (ANSI/control-char injection).
83
+ additionalContext: (0, sanitize_1.sanitizeField)(warning),
84
+ },
85
+ }),
86
+ ];
87
+ }
62
88
  if (path !== 'session-start')
63
89
  return [];
64
90
  if (responseBody == null || typeof responseBody !== 'object')
@@ -70,16 +96,19 @@ function formatHookStdoutLines(path, responseBody) {
70
96
  const sessionId = body.sessionId;
71
97
  const tb = body.taskBinding;
72
98
  if (tb && tb.bound === true) {
73
- lines.push(`[Lumo] session_id=${sessionId} | 当前任务: ${tb.taskIdentifier} - ${tb.taskTitle}`);
99
+ lines.push(`[Lumo] session_id=${sessionId} | 当前任务: ${tb.taskIdentifier} - ${(0, sanitize_1.sanitizeField)(tb.taskTitle ?? '')}`);
74
100
  }
75
101
  else if (tb && tb.bound === false) {
76
102
  lines.push(`[Lumo] session_id=${sessionId} | 当前未绑定任务。请告诉我你要处理的任务编号(如 LUM-42),或说"跳过"。`);
77
103
  }
78
- if (typeof body.memorySection === 'string' && body.memorySection !== '') {
104
+ // Memory + PR-review todos share one additionalContext block so Claude Code
105
+ // injects a single coherent context payload at session start.
106
+ const contextParts = [body.memorySection, body.reviewTodosSection].filter((s) => typeof s === 'string' && s !== '');
107
+ if (contextParts.length > 0) {
79
108
  lines.push(JSON.stringify({
80
109
  hookSpecificOutput: {
81
110
  hookEventName: 'SessionStart',
82
- additionalContext: body.memorySection,
111
+ additionalContext: contextParts.join('\n\n'),
83
112
  },
84
113
  }));
85
114
  }
@@ -92,16 +121,16 @@ function formatHookStdoutLines(path, responseBody) {
92
121
  *
93
122
  * This function NEVER throws and NEVER rejects.
94
123
  */
95
- async function runHook(path) {
124
+ async function runHook(path, agentToken) {
96
125
  const body = await readStdin();
97
- return runHookWithBody(path, body);
126
+ return runHookWithBody(path, body, agentToken);
98
127
  }
99
128
  /**
100
129
  * Test-friendly worker. Takes the hook body as an argument so tests can
101
130
  * exercise the side-effect logic without poking process.stdin. Production
102
131
  * code always goes through `runHook`, which feeds it real stdin.
103
132
  */
104
- async function runHookWithBody(path, body) {
133
+ async function runHookWithBody(path, body, agentToken) {
105
134
  try {
106
135
  const creds = (0, config_1.readCredentials)();
107
136
  if (!creds) {
@@ -111,27 +140,43 @@ async function runHookWithBody(path, body) {
111
140
  // Allow `LUMO_API_URL` to override the baked-in creds.apiUrl for
112
141
  // redirecting hooks at dev-env switch without re-running `lumo auth
113
142
  // login`. The bearer token from creds.json is still used as-is.
114
- const envUrl = process.env.LUMO_API_URL?.trim();
115
- const apiUrl = envUrl && envUrl.length > 0 ? envUrl : creds.apiUrl;
143
+ // An insecure override (non-https, non-localhost) throws here and is
144
+ // caught by the outer try/catch logged, POST skipped, hook still exits 0.
145
+ // The host-mismatch warning goes to hook.log (never stdout, which would
146
+ // pollute Claude Code).
147
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl, {
148
+ warn: message => (0, hook_log_1.logHookError)(`[${path}]`, message),
149
+ });
116
150
  const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/hooks/${path}`;
117
151
  const controller = new AbortController();
118
152
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
153
+ // Tell the server which coding agent produced this hook. The token is
154
+ // baked into the hook command at `lumo setup --agent <token>`; we send
155
+ // the normalized enum so the server can record it on the session. An
156
+ // unrecognized/absent token is dropped — the server then falls back to
157
+ // its default (CLAUDE_CODE).
158
+ const headers = {
159
+ 'Content-Type': 'application/json',
160
+ Authorization: `Bearer ${creds.token}`,
161
+ };
162
+ const agentEnum = agentToken ? (0, agent_1.normalizeAgent)(agentToken) : null;
163
+ if (agentEnum)
164
+ headers['X-Lumo-Agent'] = agentEnum;
119
165
  try {
120
166
  const res = await fetch(url, {
121
167
  method: 'POST',
122
- headers: {
123
- 'Content-Type': 'application/json',
124
- Authorization: `Bearer ${creds.token}`,
125
- },
168
+ headers,
126
169
  body: body || '{}',
127
170
  signal: controller.signal,
128
171
  });
129
172
  if (!res.ok) {
130
173
  (0, hook_log_1.logHookError)(`[${path}]`, `HTTP ${res.status} from ${url}`);
131
174
  }
132
- else if (path === 'session-start') {
133
- // Per-hook side effects fire only after a 2xx response so a transient
134
- // server failure doesn't desync local state from server state.
175
+ else if (path === 'session-start' || path === 'pre-tool-use') {
176
+ // Paths that turn the response body into stdout for Claude Code:
177
+ // session-start bind status + injected context
178
+ // pre-tool-use → parallel-edit collision warning (LUM-150 ③)
179
+ // Only after a 2xx so a transient server failure emits nothing.
135
180
  try {
136
181
  const responseBody = await res.json();
137
182
  for (const line of formatHookStdoutLines(path, responseBody)) {
@@ -35,35 +35,80 @@ exports.LUMO_HOOK_EVENTS = [
35
35
  ['CwdChanged', 'cwd-changed'],
36
36
  ['InstructionsLoaded', 'instructions-loaded'],
37
37
  ];
38
+ /** Default agent token baked into the hook commands when none is given. */
39
+ const DEFAULT_AGENT_TOKEN = 'claude-code';
40
+ /**
41
+ * A hook command belongs to Lumo when it is exactly `lumo hook <slug>` or
42
+ * carries trailing flags (`lumo hook <slug> --agent codex`). We match on the
43
+ * prefix so a re-run can recognize — and rewrite — an entry baked with a
44
+ * different (or no) `--agent` token.
45
+ */
46
+ function isLumoHookCommand(command, slug) {
47
+ return (command === `lumo hook ${slug}` || command.startsWith(`lumo hook ${slug} `));
48
+ }
38
49
  // Build the settings.json fragment we want to install. We do not use a
39
50
  // matcher because the Lumo hook handlers ingest every event of a given type;
40
51
  // adding a matcher would silently drop events that have no `tool_name` (e.g.
41
52
  // SessionStart). Keep this in step with cli/src/commands/hook.ts dispatch.
42
- function buildLumoHookFragment() {
53
+ //
54
+ // `agentToken` is baked into every command (`lumo hook <slug> --agent
55
+ // <token>`) so the hook tells the server which coding agent owns the session.
56
+ function buildLumoHookFragment(agentToken = DEFAULT_AGENT_TOKEN) {
43
57
  const hooks = {};
44
58
  for (const [event, slug] of exports.LUMO_HOOK_EVENTS) {
45
59
  hooks[event] = [
46
- { hooks: [{ type: 'command', command: `lumo hook ${slug}` }] },
60
+ {
61
+ hooks: [
62
+ {
63
+ type: 'command',
64
+ command: `lumo hook ${slug} --agent ${agentToken}`,
65
+ },
66
+ ],
67
+ },
47
68
  ];
48
69
  }
49
70
  return { hooks };
50
71
  }
51
- function mergeLumoHooks(existing) {
72
+ function mergeLumoHooks(existing, agentToken = DEFAULT_AGENT_TOKEN) {
52
73
  const base = existing
53
74
  ? JSON.parse(JSON.stringify(existing))
54
75
  : {};
55
76
  if (!base.hooks)
56
77
  base.hooks = {};
57
- const stats = { addedEvents: [], alreadyPresent: [] };
78
+ const stats = {
79
+ addedEvents: [],
80
+ alreadyPresent: [],
81
+ updatedEvents: [],
82
+ };
58
83
  for (const [event, slug] of exports.LUMO_HOOK_EVENTS) {
59
- const ourCmd = `lumo hook ${slug}`;
84
+ const ourCmd = `lumo hook ${slug} --agent ${agentToken}`;
60
85
  const groups = base.hooks[event] ?? [];
61
- const present = groups.some(g => Array.isArray(g.hooks) && g.hooks.some(h => h.command === ourCmd));
62
- if (present) {
86
+ let exact = false;
87
+ let rewritten = false;
88
+ for (const g of groups) {
89
+ if (!Array.isArray(g.hooks))
90
+ continue;
91
+ for (const h of g.hooks) {
92
+ if (h.command === ourCmd) {
93
+ exact = true;
94
+ }
95
+ else if (isLumoHookCommand(h.command, slug)) {
96
+ // legacy flagless or a different agent token — bring it up to date
97
+ h.command = ourCmd;
98
+ rewritten = true;
99
+ }
100
+ }
101
+ }
102
+ if (exact) {
63
103
  stats.alreadyPresent.push(event);
64
104
  base.hooks[event] = groups;
65
105
  continue;
66
106
  }
107
+ if (rewritten) {
108
+ stats.updatedEvents.push(event);
109
+ base.hooks[event] = groups;
110
+ continue;
111
+ }
67
112
  base.hooks[event] = [
68
113
  ...groups,
69
114
  { hooks: [{ type: 'command', command: ourCmd }] },
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
- // Category/field metadata + builders for the `lumo memory` commands.
3
- // Mirrors the four content shapes validated server-side by parseMemoryContent.
4
2
  Object.defineProperty(exports, "__esModule", { value: true });
5
3
  exports.buildMemoryContent = buildMemoryContent;
6
4
  exports.formatMemoryList = formatMemoryList;
5
+ // Category/field metadata + builders for the `lumo memory` commands.
6
+ // Mirrors the four content shapes validated server-side by parseMemoryContent.
7
+ const sanitize_1 = require("./sanitize");
7
8
  const trimmed = (v) => (v ?? '').trim();
8
9
  /**
9
10
  * Validate the per-category flags and assemble { category, content }. Required
@@ -69,7 +70,7 @@ function headline(category, content) {
69
70
  : category === 'CONVENTION' ? 'rule'
70
71
  : 'workflow';
71
72
  const v = c[key];
72
- return typeof v === 'string' && v.length > 0 ? v : '(unparseable)';
73
+ return typeof v === 'string' && v.length > 0 ? (0, sanitize_1.sanitizeField)(v) : '(unparseable)';
73
74
  }
74
75
  /** Fixed-width rows: id SCOPE CATEGORY headline source(auto|manual). */
75
76
  function formatMemoryList(rows) {
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.matchSensitiveName = matchSensitiveName;
37
+ exports.checkArtifactFilePath = checkArtifactFilePath;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const SENSITIVE_SEGMENTS = ['.ssh', '.aws', '.gnupg'];
41
+ const SENSITIVE_BASENAMES = [
42
+ 'id_rsa',
43
+ 'id_dsa',
44
+ 'id_ecdsa',
45
+ 'id_ed25519',
46
+ 'credentials',
47
+ '.npmrc',
48
+ '.netrc',
49
+ '.pgpass',
50
+ '.htpasswd',
51
+ ];
52
+ const SENSITIVE_EXTENSIONS = ['.pem', '.key', '.pfx', '.p12'];
53
+ /**
54
+ * Pure denylist check. Returns a human-readable reason when the path looks
55
+ * like a secret, or null when it is acceptable. Matches the basename
56
+ * (case-insensitive) and path segments; no filesystem access.
57
+ */
58
+ function matchSensitiveName(p) {
59
+ const segments = p.split(/[\\/]+/).filter(Boolean);
60
+ for (const seg of segments) {
61
+ if (SENSITIVE_SEGMENTS.includes(seg.toLowerCase())) {
62
+ return `path contains ${seg}`;
63
+ }
64
+ }
65
+ const base = (segments[segments.length - 1] ?? '').toLowerCase();
66
+ if (base === '.env' || base.startsWith('.env.'))
67
+ return 'matches .env';
68
+ if (SENSITIVE_BASENAMES.includes(base))
69
+ return `matches ${base}`;
70
+ for (const ext of SENSITIVE_EXTENSIONS) {
71
+ if (base.endsWith(ext))
72
+ return `matches *${ext}`;
73
+ }
74
+ return null;
75
+ }
76
+ /**
77
+ * Decide whether `rawPath` is safe to read and upload as an artifact body.
78
+ * Two layers: (1) it must resolve to a real path inside the project directory
79
+ * (cwd), and (2) neither the raw nor the canonical path may match the
80
+ * sensitive-filename denylist. realpath defeats symlinks that escape the tree.
81
+ * NOTE: there is a TOCTOU window between this check and the caller's read;
82
+ * the caller reads the returned canonical `resolved` path to narrow it.
83
+ */
84
+ function checkArtifactFilePath(rawPath, deps) {
85
+ const cwd = deps?.cwd ?? process.cwd;
86
+ const realpath = deps?.realpath ?? fs.realpathSync;
87
+ // 1. Fail fast on the raw path (no fs access).
88
+ const rawHit = matchSensitiveName(rawPath);
89
+ if (rawHit)
90
+ return { ok: false, reason: 'sensitive', detail: rawHit };
91
+ const root = cwd();
92
+ const absolute = path.resolve(root, rawPath);
93
+ // 2. Canonicalize. realpath throws if the path does not exist / is unreadable.
94
+ let resolved;
95
+ let rootReal;
96
+ try {
97
+ resolved = realpath(absolute);
98
+ rootReal = realpath(root);
99
+ }
100
+ catch {
101
+ return { ok: false, reason: 'unreadable', detail: 'path does not resolve' };
102
+ }
103
+ // 3. Re-check the denylist on the canonical path (catches innocent-looking
104
+ // symlinks pointing at a secret).
105
+ const canonHit = matchSensitiveName(resolved);
106
+ if (canonHit)
107
+ return { ok: false, reason: 'sensitive', detail: canonHit };
108
+ // The path must be a file inside the project, not the project root itself.
109
+ if (resolved === rootReal) {
110
+ return {
111
+ ok: false,
112
+ reason: 'outside-project',
113
+ detail: 'path resolves to the project root, not a file',
114
+ };
115
+ }
116
+ // 4. Confinement: the canonical path must be inside the project root.
117
+ if (resolved !== rootReal && !resolved.startsWith(rootReal + path.sep)) {
118
+ return {
119
+ ok: false,
120
+ reason: 'outside-project',
121
+ detail: 'resolves outside project directory',
122
+ };
123
+ }
124
+ return { ok: true, resolved };
125
+ }
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchProgressDraft = fetchProgressDraft;
4
+ exports.postProgressComment = postProgressComment;
5
+ const api_1 = require("./api");
6
+ function base(creds) {
7
+ return (0, api_1.trimTrailingSlash)((0, api_1.resolveAuthedApiUrl)(creds.apiUrl));
8
+ }
9
+ /** GET the unposted progress draft for the session. Throws on transport / non-200. */
10
+ async function fetchProgressDraft(creds, sessionId) {
11
+ const url = `${base(creds)}/api/sessions/${encodeURIComponent(sessionId)}/turn-summaries`;
12
+ const res = await fetch(url, {
13
+ headers: { Authorization: `Bearer ${creds.token}` },
14
+ });
15
+ if (res.status === 401)
16
+ throw new Error('API key invalid or revoked. Run `lumo auth login`.');
17
+ if (!res.ok)
18
+ throw new Error(`progress draft fetch failed (HTTP ${res.status})`);
19
+ return (await res.json());
20
+ }
21
+ /** POST the (possibly edited) body + watermark. Throws the server message on non-201. */
22
+ async function postProgressComment(creds, sessionId, payload) {
23
+ const url = `${base(creds)}/api/sessions/${encodeURIComponent(sessionId)}/progress-comment`;
24
+ const res = await fetch(url, {
25
+ method: 'POST',
26
+ headers: {
27
+ Authorization: `Bearer ${creds.token}`,
28
+ 'Content-Type': 'application/json',
29
+ },
30
+ body: JSON.stringify(payload),
31
+ });
32
+ if (res.status === 401)
33
+ throw new Error('API key invalid or revoked. Run `lumo auth login`.');
34
+ if (res.status !== 201) {
35
+ let serverMsg = null;
36
+ try {
37
+ const errBody = (await res.json());
38
+ if (typeof errBody.error === 'string')
39
+ serverMsg = errBody.error;
40
+ }
41
+ catch {
42
+ // body wasn't JSON
43
+ }
44
+ throw new Error(serverMsg ?? `progress comment failed (HTTP ${res.status})`);
45
+ }
46
+ return (await res.json());
47
+ }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.lookupDocId = lookupDocId;
4
4
  const api_1 = require("./api");
5
5
  const resolve_doc_1 = require("./resolve-doc");
6
+ const sanitize_1 = require("./sanitize");
6
7
  /**
7
8
  * Resolve a user-typed doc reference (cuid OR case-insensitive title) to a
8
9
  * document id. Fetches GET /api/documents to perform a title lookup when
@@ -25,7 +26,7 @@ async function lookupDocId(apiUrl, token, reference) {
25
26
  if (result.kind === 'ambiguous') {
26
27
  console.error(`Error: title "${reference}" matches ${result.candidates.length} docs:`);
27
28
  for (const c of result.candidates) {
28
- console.error(` ${c.id} ${c.title}`);
29
+ console.error(` ${c.id} ${(0, sanitize_1.sanitizeField)(c.title)}`);
29
30
  }
30
31
  console.error('Re-run with the cuid.');
31
32
  return null;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.fetchMembers = fetchMembers;
4
4
  exports.resolveMember = resolveMember;
5
5
  const api_1 = require("./api");
6
+ const sanitize_1 = require("./sanitize");
6
7
  /**
7
8
  * Fetch the workspace member directory once. Used by doc-share commands to
8
9
  * resolve a free-form member ref → memberId locally, because the document
@@ -16,7 +17,7 @@ async function fetchMembers(apiUrl, token) {
16
17
  });
17
18
  if (!res.ok) {
18
19
  const text = await res.text();
19
- throw new Error(`failed to load members (${res.status} ${res.statusText}): ${text}`);
20
+ throw new Error(`failed to load members (${res.status} ${res.statusText}): ${(0, sanitize_1.sanitizeField)(text)}`);
20
21
  }
21
22
  const body = (await res.json());
22
23
  return body.members.map(m => ({
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sanitizeField = sanitizeField;
4
+ // Matches C0 control chars EXCEPT tab (\x09) and newline (\x0a), DEL (\x7f),
5
+ // and the C1 control range (\x80-\x9f). Includes ESC (\x1b) and the 8-bit CSI
6
+ // introducer (\x9b) — both ANSI escape-sequence injection vectors. U+0080-U+009F
7
+ // are never legitimate visible text, so stripping them is safe.
8
+ const CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
9
+ /**
10
+ * Strip terminal control characters from untrusted, server-returned fields
11
+ * before printing them, to prevent ANSI escape-sequence injection. Tab and
12
+ * newline are preserved so fixed-width tables and multi-line bodies render
13
+ * correctly. Callers handle optional values, e.g. `sanitizeField(name ?? '')`.
14
+ */
15
+ function sanitizeField(value) {
16
+ return value.replace(CONTROL_CHARS, '');
17
+ }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveTagRefs = resolveTagRefs;
4
4
  const api_1 = require("./api");
5
+ const sanitize_1 = require("./sanitize");
5
6
  async function resolveTagRefs(refs, deps) {
6
7
  const fetchImpl = deps.fetchImpl ?? fetch;
7
8
  // Trim + reject empty
@@ -36,7 +37,7 @@ async function resolveTagRefs(refs, deps) {
36
37
  });
37
38
  if (!res.ok) {
38
39
  const body = await res.text();
39
- throw new Error(`tag resolve failed: ${res.status} ${res.statusText}: ${body}`);
40
+ throw new Error(`tag resolve failed: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(body)}`);
40
41
  }
41
42
  const json = (await res.json());
42
43
  nameIds = json.tags.map(t => t.id);
@@ -40,10 +40,10 @@ exports.maybeRefreshInBackground = maybeRefreshInBackground;
40
40
  exports.runBackgroundRefresh = runBackgroundRefresh;
41
41
  const fs = __importStar(require("fs"));
42
42
  const path = __importStar(require("path"));
43
- const os = __importStar(require("os"));
44
43
  const https = __importStar(require("https"));
45
44
  const child_process_1 = require("child_process");
46
- const CACHE_FILE = path.join(process.env.LUMO_CONFIG_DIR || path.join(os.homedir(), '.lumo'), 'update-check.json');
45
+ const config_1 = require("./config");
46
+ const CACHE_FILE = path.join((0, config_1.configDir)(), 'update-check.json');
47
47
  const CHECK_INTERVAL_MS = 1000 * 60 * 60 * 24;
48
48
  function readCache() {
49
49
  try {
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runWrapPanel = runWrapPanel;
4
+ /** Run each section in order; print its title, prepare, then run if it has content. */
5
+ async function runWrapPanel(sections, opts) {
6
+ for (const section of sections) {
7
+ process.stdout.write(`\n━━ ${section.title} ━━\n`);
8
+ const hasContent = await section.prepare();
9
+ if (!hasContent) {
10
+ process.stdout.write('(无内容)\n');
11
+ continue;
12
+ }
13
+ await section.run(opts);
14
+ }
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",