@lumoai/cli 1.4.0 → 1.5.1

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 (83) hide show
  1. package/assets/skill.md +228 -17
  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 +86 -0
  19. package/dist/cli/src/commands/memory-project-list.js +58 -0
  20. package/dist/cli/src/commands/memory-promote.js +52 -0
  21. package/dist/cli/src/commands/memory-rm.js +42 -0
  22. package/dist/cli/src/commands/memory-task-add.js +99 -0
  23. package/dist/cli/src/commands/memory-task-list.js +61 -0
  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/setup.js +33 -7
  34. package/dist/cli/src/commands/sprint-add.js +3 -3
  35. package/dist/cli/src/commands/sprint-close.js +5 -5
  36. package/dist/cli/src/commands/sprint-create.js +4 -4
  37. package/dist/cli/src/commands/sprint-delete.js +5 -5
  38. package/dist/cli/src/commands/sprint-list.js +3 -3
  39. package/dist/cli/src/commands/sprint-remove.js +3 -3
  40. package/dist/cli/src/commands/sprint-show.js +4 -4
  41. package/dist/cli/src/commands/sprint-start.js +4 -4
  42. package/dist/cli/src/commands/sprint-summary.js +7 -7
  43. package/dist/cli/src/commands/sprint-update.js +6 -5
  44. package/dist/cli/src/commands/task-artifact-add.js +35 -6
  45. package/dist/cli/src/commands/task-artifact-list.js +5 -3
  46. package/dist/cli/src/commands/task-artifact-rm.js +4 -4
  47. package/dist/cli/src/commands/task-artifact-show.js +9 -7
  48. package/dist/cli/src/commands/task-artifact-update.js +15 -6
  49. package/dist/cli/src/commands/task-comment-list.js +111 -0
  50. package/dist/cli/src/commands/task-comment.js +3 -3
  51. package/dist/cli/src/commands/task-context.js +24 -12
  52. package/dist/cli/src/commands/task-create.js +7 -7
  53. package/dist/cli/src/commands/task-figma-add.js +3 -2
  54. package/dist/cli/src/commands/task-figma-context.js +61 -0
  55. package/dist/cli/src/commands/task-figma-list.js +3 -2
  56. package/dist/cli/src/commands/task-figma-refresh.js +4 -3
  57. package/dist/cli/src/commands/task-figma-rm.js +3 -2
  58. package/dist/cli/src/commands/task-list.js +1 -2
  59. package/dist/cli/src/commands/task-pr-show.js +66 -0
  60. package/dist/cli/src/commands/task-show.js +8 -7
  61. package/dist/cli/src/commands/task-slack-show.js +59 -0
  62. package/dist/cli/src/commands/task-update.js +7 -7
  63. package/dist/cli/src/commands/task-web-show.js +64 -0
  64. package/dist/cli/src/commands/whoami.js +4 -3
  65. package/dist/cli/src/index.js +239 -102
  66. package/dist/cli/src/lib/agent.js +58 -0
  67. package/dist/cli/src/lib/api.js +81 -1
  68. package/dist/cli/src/lib/config.js +2 -1
  69. package/dist/cli/src/lib/doc-input.js +12 -1
  70. package/dist/cli/src/lib/figma-api.js +1 -1
  71. package/dist/cli/src/lib/format.js +3 -2
  72. package/dist/cli/src/lib/hook-runner.js +26 -10
  73. package/dist/cli/src/lib/hooks-template.js +52 -7
  74. package/dist/cli/src/lib/memory-content.js +88 -0
  75. package/dist/cli/src/lib/path-guard.js +125 -0
  76. package/dist/cli/src/lib/resolve-bound-task.js +31 -0
  77. package/dist/cli/src/lib/resolve-doc-id.js +2 -1
  78. package/dist/cli/src/lib/resolve-member.js +2 -1
  79. package/dist/cli/src/lib/resolve-project.js +24 -0
  80. package/dist/cli/src/lib/sanitize.js +17 -0
  81. package/dist/cli/src/lib/tag-resolver.js +2 -1
  82. package/dist/cli/src/lib/update-check.js +2 -2
  83. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatTaskListTable = formatTaskListTable;
4
+ const sanitize_1 = require("./sanitize");
4
5
  /**
5
6
  * Render a task list as a fixed-width table. Each row:
6
7
  * LUM-42 IN_PROGRESS HIGH project-name Title here
@@ -14,8 +15,8 @@ function formatTaskListTable(tasks) {
14
15
  identifier: `${t.teamIdentifier}-${t.number}`,
15
16
  status: t.status,
16
17
  priority: t.priority,
17
- project: t.project.name,
18
- title: t.title,
18
+ project: (0, sanitize_1.sanitizeField)(t.project.name),
19
+ title: (0, sanitize_1.sanitizeField)(t.title),
19
20
  }));
20
21
  const widths = {
21
22
  identifier: Math.max(...rows.map(r => r.identifier.length)),
@@ -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
@@ -70,7 +72,7 @@ function formatHookStdoutLines(path, responseBody) {
70
72
  const sessionId = body.sessionId;
71
73
  const tb = body.taskBinding;
72
74
  if (tb && tb.bound === true) {
73
- lines.push(`[Lumo] session_id=${sessionId} | 当前任务: ${tb.taskIdentifier} - ${tb.taskTitle}`);
75
+ lines.push(`[Lumo] session_id=${sessionId} | 当前任务: ${tb.taskIdentifier} - ${(0, sanitize_1.sanitizeField)(tb.taskTitle ?? '')}`);
74
76
  }
75
77
  else if (tb && tb.bound === false) {
76
78
  lines.push(`[Lumo] session_id=${sessionId} | 当前未绑定任务。请告诉我你要处理的任务编号(如 LUM-42),或说"跳过"。`);
@@ -92,16 +94,16 @@ function formatHookStdoutLines(path, responseBody) {
92
94
  *
93
95
  * This function NEVER throws and NEVER rejects.
94
96
  */
95
- async function runHook(path) {
97
+ async function runHook(path, agentToken) {
96
98
  const body = await readStdin();
97
- return runHookWithBody(path, body);
99
+ return runHookWithBody(path, body, agentToken);
98
100
  }
99
101
  /**
100
102
  * Test-friendly worker. Takes the hook body as an argument so tests can
101
103
  * exercise the side-effect logic without poking process.stdin. Production
102
104
  * code always goes through `runHook`, which feeds it real stdin.
103
105
  */
104
- async function runHookWithBody(path, body) {
106
+ async function runHookWithBody(path, body, agentToken) {
105
107
  try {
106
108
  const creds = (0, config_1.readCredentials)();
107
109
  if (!creds) {
@@ -111,18 +113,32 @@ async function runHookWithBody(path, body) {
111
113
  // Allow `LUMO_API_URL` to override the baked-in creds.apiUrl for
112
114
  // redirecting hooks at dev-env switch without re-running `lumo auth
113
115
  // 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;
116
+ // An insecure override (non-https, non-localhost) throws here and is
117
+ // caught by the outer try/catch logged, POST skipped, hook still exits 0.
118
+ // The host-mismatch warning goes to hook.log (never stdout, which would
119
+ // pollute Claude Code).
120
+ const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl, {
121
+ warn: message => (0, hook_log_1.logHookError)(`[${path}]`, message),
122
+ });
116
123
  const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/hooks/${path}`;
117
124
  const controller = new AbortController();
118
125
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
126
+ // Tell the server which coding agent produced this hook. The token is
127
+ // baked into the hook command at `lumo setup --agent <token>`; we send
128
+ // the normalized enum so the server can record it on the session. An
129
+ // unrecognized/absent token is dropped — the server then falls back to
130
+ // its default (CLAUDE_CODE).
131
+ const headers = {
132
+ 'Content-Type': 'application/json',
133
+ Authorization: `Bearer ${creds.token}`,
134
+ };
135
+ const agentEnum = agentToken ? (0, agent_1.normalizeAgent)(agentToken) : null;
136
+ if (agentEnum)
137
+ headers['X-Lumo-Agent'] = agentEnum;
119
138
  try {
120
139
  const res = await fetch(url, {
121
140
  method: 'POST',
122
- headers: {
123
- 'Content-Type': 'application/json',
124
- Authorization: `Bearer ${creds.token}`,
125
- },
141
+ headers,
126
142
  body: body || '{}',
127
143
  signal: controller.signal,
128
144
  });
@@ -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 }] },
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildMemoryContent = buildMemoryContent;
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");
8
+ const trimmed = (v) => (v ?? '').trim();
9
+ /**
10
+ * Validate the per-category flags and assemble { category, content }. Required
11
+ * fields must be non-empty; optional string fields are dropped when blank;
12
+ * `--applies` maps to convention `content.scope`; repeated `--step` becomes a
13
+ * trimmed, empty-filtered `steps` array (omitted when empty).
14
+ */
15
+ function buildMemoryContent(category, flags) {
16
+ const cat = category?.toLowerCase();
17
+ switch (cat) {
18
+ case 'trap': {
19
+ const trigger = trimmed(flags.trigger);
20
+ const outcome = trimmed(flags.outcome);
21
+ const workaround = trimmed(flags.workaround);
22
+ if (!trigger)
23
+ return { ok: false, error: '--trigger is required for category trap.' };
24
+ if (!outcome)
25
+ return { ok: false, error: '--outcome is required for category trap.' };
26
+ return { ok: true, category: 'TRAP', content: { trigger, outcome, ...(workaround ? { workaround } : {}) } };
27
+ }
28
+ case 'decision': {
29
+ const what = trimmed(flags.what);
30
+ const why = trimmed(flags.why);
31
+ const alternatives = trimmed(flags.alternatives);
32
+ const implications = trimmed(flags.implications);
33
+ if (!what)
34
+ return { ok: false, error: '--what is required for category decision.' };
35
+ if (!why)
36
+ return { ok: false, error: '--why is required for category decision.' };
37
+ return {
38
+ ok: true,
39
+ category: 'DECISION',
40
+ content: { what, why, ...(alternatives ? { alternatives } : {}), ...(implications ? { implications } : {}) },
41
+ };
42
+ }
43
+ case 'convention': {
44
+ const rule = trimmed(flags.rule);
45
+ const applies = trimmed(flags.applies);
46
+ if (!rule)
47
+ return { ok: false, error: '--rule is required for category convention.' };
48
+ if (!applies)
49
+ return { ok: false, error: '--applies is required for category convention (where the rule applies).' };
50
+ return { ok: true, category: 'CONVENTION', content: { rule, scope: applies } };
51
+ }
52
+ case 'procedural': {
53
+ const workflow = trimmed(flags.workflow);
54
+ const trigger = trimmed(flags.trigger);
55
+ const steps = (flags.step ?? []).map(s => s.trim()).filter(Boolean);
56
+ if (!workflow)
57
+ return { ok: false, error: '--workflow is required for category procedural.' };
58
+ if (!trigger)
59
+ return { ok: false, error: '--trigger is required for category procedural.' };
60
+ return { ok: true, category: 'PROCEDURAL', content: { workflow, trigger, ...(steps.length ? { steps } : {}) } };
61
+ }
62
+ default:
63
+ return { ok: false, error: `Unknown --category "${category}". Use trap | decision | convention | procedural.` };
64
+ }
65
+ }
66
+ function headline(category, content) {
67
+ const c = (content ?? {});
68
+ const key = category === 'TRAP' ? 'trigger'
69
+ : category === 'DECISION' ? 'what'
70
+ : category === 'CONVENTION' ? 'rule'
71
+ : 'workflow';
72
+ const v = c[key];
73
+ return typeof v === 'string' && v.length > 0 ? (0, sanitize_1.sanitizeField)(v) : '(unparseable)';
74
+ }
75
+ /** Fixed-width rows: id SCOPE CATEGORY headline source(auto|manual). */
76
+ function formatMemoryList(rows) {
77
+ if (rows.length === 0)
78
+ return 'No memories.';
79
+ const idW = Math.max(...rows.map(r => r.id.length));
80
+ const scopeW = Math.max(...rows.map(r => r.scope.length));
81
+ const catW = Math.max(...rows.map(r => r.category.length));
82
+ return rows
83
+ .map(r => {
84
+ const src = r.createdByMemberId ? 'manual' : 'auto';
85
+ return `${r.id.padEnd(idW)} ${r.scope.padEnd(scopeW)} ${r.category.padEnd(catW)} ${headline(r.category, r.content)} ${src}`;
86
+ })
87
+ .join('\n');
88
+ }
@@ -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,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveBoundTaskIdentifier = resolveBoundTaskIdentifier;
4
+ const api_1 = require("./api");
5
+ /**
6
+ * The task identifier (LUM-N) the current Claude Code session is bound to, or
7
+ * null when there is no session env, no server-side session row, or no binding.
8
+ * Used by the memory commands to default their target to the bound task.
9
+ */
10
+ async function resolveBoundTaskIdentifier(apiUrl, token) {
11
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
12
+ if (!sessionId)
13
+ return null;
14
+ const url = `${(0, api_1.trimTrailingSlash)(apiUrl)}/api/sessions/${encodeURIComponent(sessionId)}`;
15
+ let res;
16
+ try {
17
+ res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ if (!res.ok)
23
+ return null;
24
+ try {
25
+ const data = (await res.json());
26
+ return data.taskIdentifier ?? null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
@@ -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,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveBoundProjectId = resolveBoundProjectId;
4
+ const api_1 = require("./api");
5
+ /** Resolve the project id of the task bound to this session (via its identifier). */
6
+ async function resolveBoundProjectId(apiUrl, token, boundIdentifier) {
7
+ const base = (0, api_1.trimTrailingSlash)(apiUrl);
8
+ let res;
9
+ try {
10
+ res = await fetch(`${base}/api/tasks/by-identifier/${encodeURIComponent(boundIdentifier)}`, {
11
+ headers: { Authorization: `Bearer ${token}` },
12
+ });
13
+ }
14
+ catch (err) {
15
+ return { ok: false, error: `could not reach Lumo API (${err instanceof Error ? err.message : String(err)})` };
16
+ }
17
+ if (!res.ok)
18
+ return { ok: false, error: `could not resolve bound task ${boundIdentifier} (HTTP ${res.status})` };
19
+ const { task } = (await res.json());
20
+ const id = task.project?.id;
21
+ if (!id)
22
+ return { ok: false, error: `bound task ${boundIdentifier} has no resolvable project` };
23
+ return { ok: true, id };
24
+ }
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",