@chronicle.app/shell 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @chronicle.app/shell
2
+
3
+ `ShellHistoryExtractor` reads bash, zsh, and fish histories; `ShellHistoryTransformer` emits ExecuteAction/Command records with the local username and machine realm. Pass `input` and optionally `shell` (`auto`, `bash`, `zsh`, `fish`). With an explicit file, auto-detection examines that file; otherwise standard history paths are used. Transformer config can override `username` and `hostname` for exported histories.
4
+
5
+ Records are yielded in reverse file order; limits apply after inclusive `since`/`until` filtering. `limit: 0` is unlimited. Untimestamped or malformed-time entries remain available as raw records (unless a time window is requested), but do not become fabricated dated actions. No commands from history are executed.
6
+
7
+ The unified extractor replaces the old deprecated Bash/Zsh classes. It reads the file in memory. Multiline shell syntax is not reconstructed; use raw records when the history format does not provide one complete command per entry.
8
+
9
+ Tests use temporary history files and an explicit fixture identity, never personal history.
@@ -0,0 +1,46 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import ShellHistoryTransformer from './ShellHistoryTransformer.js';
4
+ export declare class ShellHistoryExtractor extends Extractor<typeof ShellHistoryExtractor> {
5
+ static source: string;
6
+ static description: string;
7
+ static delivery: "local";
8
+ static strategy: string;
9
+ static recordTypes: string[];
10
+ static default: boolean;
11
+ static defaultTransformer: typeof ShellHistoryTransformer;
12
+ static schema: z.ZodObject<{
13
+ since: z.ZodOptional<z.ZodDate>;
14
+ until: z.ZodOptional<z.ZodDate>;
15
+ limit: z.ZodOptional<z.ZodNumber>;
16
+ } & {
17
+ shell: z.ZodDefault<z.ZodEnum<["auto", "zsh", "bash", "fish"]>>;
18
+ input: z.ZodOptional<z.ZodString>;
19
+ }, "strip", z.ZodTypeAny, {
20
+ shell: "auto" | "zsh" | "bash" | "fish";
21
+ input?: string | undefined;
22
+ since?: Date | undefined;
23
+ until?: Date | undefined;
24
+ limit?: number | undefined;
25
+ }, {
26
+ shell?: "auto" | "zsh" | "bash" | "fish" | undefined;
27
+ input?: string | undefined;
28
+ since?: Date | undefined;
29
+ until?: Date | undefined;
30
+ limit?: number | undefined;
31
+ }>;
32
+ private detectShellType;
33
+ private getDefaultHistoryPath;
34
+ extract(): AsyncGenerator<Record>;
35
+ /**
36
+ * Yield parsed entries newest-first so `--limit` caps the most recent
37
+ * commands. History files are append-only logs (oldest at the top), so
38
+ * this is a reversal of file order.
39
+ */
40
+ private yieldNewestFirst;
41
+ private parseZshHistory;
42
+ private parseBashHistory;
43
+ private parseFishHistory;
44
+ determineCount(): Promise<number | null>;
45
+ }
46
+ //# sourceMappingURL=ShellHistoryExtractor.d.ts.map
@@ -0,0 +1,291 @@
1
+ import { Extractor } from '@chronicle.app/etl';
2
+ import { readFile, access } from 'node:fs/promises';
3
+ import { z } from 'zod';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import ShellHistoryTransformer from './ShellHistoryTransformer.js';
7
+ const SUPPORTED_SHELLS = ['auto', 'zsh', 'bash', 'fish'];
8
+ export class ShellHistoryExtractor extends Extractor {
9
+ static source = 'shell';
10
+ static description = 'Command history (bash, zsh, or fish, auto-detected)';
11
+ static delivery = 'local';
12
+ static strategy = 'history';
13
+ static recordTypes = ['commands'];
14
+ static default = true;
15
+ static defaultTransformer = ShellHistoryTransformer;
16
+ static schema = Extractor.schema.extend({
17
+ shell: z
18
+ .enum(SUPPORTED_SHELLS)
19
+ .default('auto')
20
+ .describe('Shell type to extract from (auto-detects if not specified)'),
21
+ input: z
22
+ .string()
23
+ .optional()
24
+ .describe('Path to the shell history file (auto-detects based on shell type if not specified)'),
25
+ });
26
+ async detectShellType() {
27
+ if (this.config.input) {
28
+ const content = await readFile(this.config.input, 'utf8');
29
+ if (/^: \d+:\d+;/m.test(content))
30
+ return 'zsh';
31
+ if (/^- cmd: /m.test(content))
32
+ return 'fish';
33
+ if (/^#\d+$/m.test(content))
34
+ return 'bash';
35
+ return path.basename(this.config.input).includes('zsh') ? 'zsh' : 'bash';
36
+ }
37
+ const homeDir = os.homedir();
38
+ // Check for common shell history files in order of preference
39
+ const shellChecks = [
40
+ { shell: 'zsh', path: path.join(homeDir, '.zsh_history') },
41
+ { shell: 'bash', path: path.join(homeDir, '.bash_history') },
42
+ {
43
+ shell: 'fish',
44
+ path: path.join(homeDir, '.local/share/fish/fish_history'),
45
+ },
46
+ ];
47
+ for (const { shell, path: filePath } of shellChecks) {
48
+ try {
49
+ await access(filePath);
50
+ return shell;
51
+ }
52
+ catch {
53
+ // File doesn't exist, continue to next
54
+ }
55
+ }
56
+ // Fallback to environment variable or default to zsh
57
+ const shellEnv = process.env.SHELL?.toLowerCase() || '';
58
+ if (shellEnv.includes('zsh'))
59
+ return 'zsh';
60
+ if (shellEnv.includes('bash'))
61
+ return 'bash';
62
+ if (shellEnv.includes('fish'))
63
+ return 'fish';
64
+ // Default fallback
65
+ return 'zsh';
66
+ }
67
+ getDefaultHistoryPath(shellType) {
68
+ const homeDir = os.homedir();
69
+ switch (shellType) {
70
+ case 'zsh':
71
+ return path.join(homeDir, '.zsh_history');
72
+ case 'bash':
73
+ return path.join(homeDir, '.bash_history');
74
+ case 'fish':
75
+ return path.join(homeDir, '.local/share/fish/fish_history');
76
+ default:
77
+ return path.join(homeDir, '.zsh_history');
78
+ }
79
+ }
80
+ async *extract() {
81
+ const config = this.config;
82
+ // Determine shell type
83
+ const shellType = config.shell === 'auto' ? await this.detectShellType() : config.shell;
84
+ // Determine input file path
85
+ const inputPath = config.input || this.getDefaultHistoryPath(shellType);
86
+ try {
87
+ const content = await readFile(inputPath, 'utf-8');
88
+ // Delegate to appropriate parser
89
+ switch (shellType) {
90
+ case 'zsh':
91
+ yield* this.parseZshHistory(content, inputPath);
92
+ break;
93
+ case 'bash':
94
+ yield* this.parseBashHistory(content, inputPath);
95
+ break;
96
+ case 'fish':
97
+ yield* this.parseFishHistory(content, inputPath);
98
+ break;
99
+ default:
100
+ throw new Error(`Unsupported shell type: ${shellType}`);
101
+ }
102
+ }
103
+ catch (error) {
104
+ throw new Error(`Failed to read ${shellType} history file from ${inputPath}: ${error}`);
105
+ }
106
+ }
107
+ /**
108
+ * Yield parsed entries newest-first so `--limit` caps the most recent
109
+ * commands. History files are append-only logs (oldest at the top), so
110
+ * this is a reversal of file order.
111
+ */
112
+ async *yieldNewestFirst(entries) {
113
+ let commandCount = 0;
114
+ for (let i = entries.length - 1; i >= 0; i--) {
115
+ if (this.shouldStopExtracting(commandCount))
116
+ break;
117
+ const { data, metadata } = entries[i];
118
+ if (this.config.since || this.config.until) {
119
+ if (!data.timestamp)
120
+ continue;
121
+ const time = new Date(data.timestamp).getTime();
122
+ if (this.config.since && time < this.config.since.getTime())
123
+ continue;
124
+ if (this.config.until && time > this.config.until.getTime())
125
+ continue;
126
+ }
127
+ yield this.createRecord(data, metadata);
128
+ commandCount++;
129
+ }
130
+ }
131
+ async *parseZshHistory(content, inputPath) {
132
+ const lines = content.split('\n').filter(line => line.trim());
133
+ const entries = [];
134
+ for (const [index, line] of lines.entries()) {
135
+ if (!line.trim())
136
+ continue;
137
+ // Parse zsh extended history format: : <timestamp>:<elapsed>;<command>
138
+ const zshExtendedMatch = line.match(/^: (\d+):\d+;(.*)$/);
139
+ let command;
140
+ let timestamp;
141
+ if (zshExtendedMatch) {
142
+ const [, timestampStr, cmd] = zshExtendedMatch;
143
+ const parsedTimestamp = Number.parseInt(timestampStr, 10);
144
+ if (Number.isNaN(parsedTimestamp)) {
145
+ timestamp = null;
146
+ }
147
+ else {
148
+ try {
149
+ timestamp = new Date(parsedTimestamp * 1000).toISOString();
150
+ }
151
+ catch {
152
+ timestamp = null;
153
+ }
154
+ }
155
+ command = cmd.trim();
156
+ }
157
+ else {
158
+ command = line.trim();
159
+ timestamp = null;
160
+ }
161
+ if (!command)
162
+ continue;
163
+ entries.push({
164
+ data: {
165
+ command,
166
+ timestamp,
167
+ originalLine: line,
168
+ shell: 'zsh',
169
+ historyFile: inputPath,
170
+ },
171
+ metadata: {
172
+ lineNumber: index + 1,
173
+ hasTimestamp: zshExtendedMatch !== null,
174
+ format: zshExtendedMatch ? 'extended' : 'simple',
175
+ },
176
+ });
177
+ }
178
+ yield* this.yieldNewestFirst(entries);
179
+ }
180
+ async *parseBashHistory(content, inputPath) {
181
+ const lines = content.split('\n').filter(line => line.trim());
182
+ let currentTimestamp = null;
183
+ const entries = [];
184
+ for (const [index, line] of lines.entries()) {
185
+ if (!line.trim())
186
+ continue;
187
+ // Check if this is a timestamp line (starts with #)
188
+ if (line.startsWith('#')) {
189
+ const timestampStr = line.slice(1);
190
+ const parsedTimestamp = /^\d+$/.test(timestampStr) ? Number(timestampStr) : Number.NaN;
191
+ currentTimestamp = Number.isNaN(parsedTimestamp) ? null : parsedTimestamp;
192
+ continue;
193
+ }
194
+ const command = line.trim();
195
+ // Create timestamp
196
+ let timestamp;
197
+ if (currentTimestamp !== null && !Number.isNaN(currentTimestamp)) {
198
+ try {
199
+ timestamp = new Date(currentTimestamp * 1000).toISOString();
200
+ }
201
+ catch {
202
+ timestamp = null;
203
+ }
204
+ }
205
+ else {
206
+ timestamp = null;
207
+ }
208
+ entries.push({
209
+ data: {
210
+ command,
211
+ timestamp,
212
+ originalLine: line,
213
+ shell: 'bash',
214
+ historyFile: inputPath,
215
+ },
216
+ metadata: {
217
+ lineNumber: index + 1,
218
+ hasTimestamp: currentTimestamp !== null,
219
+ },
220
+ });
221
+ }
222
+ yield* this.yieldNewestFirst(entries);
223
+ }
224
+ async *parseFishHistory(content, inputPath) {
225
+ const lines = content.split('\n').filter(line => line.trim());
226
+ const entries = [];
227
+ let currentCommand = '';
228
+ let currentTimestamp = null;
229
+ for (const [index, line] of lines.entries()) {
230
+ if (!line.trim())
231
+ continue;
232
+ if (line.startsWith('- cmd: ')) {
233
+ currentCommand = line.slice(7); // Remove "- cmd: "
234
+ }
235
+ else if (line.startsWith(' when: ')) {
236
+ const timestampStr = line.slice(8); // Remove " when: "
237
+ const parsedTimestamp = Number.parseInt(timestampStr, 10);
238
+ try {
239
+ currentTimestamp = new Date(parsedTimestamp * 1000).toISOString();
240
+ }
241
+ catch {
242
+ currentTimestamp = null;
243
+ }
244
+ // We have both command and timestamp, record the entry
245
+ if (currentCommand) {
246
+ entries.push({
247
+ data: {
248
+ command: currentCommand,
249
+ timestamp: currentTimestamp,
250
+ originalLine: `${currentCommand} (${currentTimestamp})`,
251
+ shell: 'fish',
252
+ historyFile: inputPath,
253
+ },
254
+ metadata: {
255
+ lineNumber: index + 1,
256
+ hasTimestamp: true,
257
+ },
258
+ });
259
+ currentCommand = '';
260
+ currentTimestamp = null;
261
+ }
262
+ }
263
+ }
264
+ yield* this.yieldNewestFirst(entries);
265
+ }
266
+ async determineCount() {
267
+ const config = this.config;
268
+ try {
269
+ const shellType = config.shell === 'auto' ? await this.detectShellType() : config.shell;
270
+ const inputPath = config.input || this.getDefaultHistoryPath(shellType);
271
+ const content = await readFile(inputPath, 'utf-8');
272
+ const lines = content.split('\n').filter(line => line.trim());
273
+ switch (shellType) {
274
+ case 'zsh':
275
+ return lines.filter(line => {
276
+ const trimmed = line.trim();
277
+ return trimmed && (trimmed.match(/^: \d+:\d+;/) || !trimmed.startsWith(':'));
278
+ }).length;
279
+ case 'bash':
280
+ return lines.filter(line => !line.startsWith('#')).length;
281
+ case 'fish':
282
+ return lines.filter(line => line.startsWith('- cmd: ')).length;
283
+ default:
284
+ return lines.length;
285
+ }
286
+ }
287
+ catch {
288
+ return null;
289
+ }
290
+ }
291
+ }
@@ -0,0 +1,9 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class ShellHistoryTransformer extends ChronicleTransformer {
4
+ transform(record: Record): Promise<ActionAndChildren[]>;
5
+ private buildCommandActions;
6
+ private buildUser;
7
+ private buildCommand;
8
+ }
9
+ //# sourceMappingURL=ShellHistoryTransformer.d.ts.map
@@ -0,0 +1,69 @@
1
+ import { hostname, userInfo } from 'node:os';
2
+ import { ChronicleTransformer } from '@chronicle.app/etl';
3
+ export default class ShellHistoryTransformer extends ChronicleTransformer {
4
+ async transform(record) {
5
+ const actions = [];
6
+ if (record.extraction.recordType === 'commands') {
7
+ actions.push(...(await this.buildCommandActions(record)));
8
+ }
9
+ return actions;
10
+ }
11
+ async buildCommandActions(record) {
12
+ // The timestamp is part of the key, so it must be stable across
13
+ // re-extractions — pass through what the extractor asserted, never stamp
14
+ // one here (a transform-time clock would mint a new CID every run). A
15
+ // record without a parseable timestamp is dropped rather than fabricated.
16
+ const timestamp = new Date(record.data.timestamp ?? Number.NaN);
17
+ if (Number.isNaN(timestamp.getTime()))
18
+ return [];
19
+ const executeAction = {
20
+ '@type': 'ExecuteAction',
21
+ timestamp,
22
+ '@key': [
23
+ '@type',
24
+ 'source',
25
+ 'timestamp',
26
+ 'object.body',
27
+ 'agent.handle',
28
+ 'agent.memberOf[*].handle',
29
+ ],
30
+ source: 'shell',
31
+ agent: await this.buildUser(),
32
+ object: this.buildCommand(record.data),
33
+ };
34
+ return [executeAction];
35
+ }
36
+ async buildUser() {
37
+ // The user account, keyed by its username within the realm (the host) it
38
+ // belongs to — reached by traversing `memberOf` to the realm's handle, so
39
+ // the key is (username, machine) without a denormalized namespace scalar.
40
+ // `sameAs: ['@me']` still merges it onto the account owner. Only the stable
41
+ // machine name (from SystemInfo) is kept — not the full hostname, which
42
+ // flips with Tailscale/Bonjour and would thrash a re-observed `name`.
43
+ // The logical machine as a name-keyed :Realm — folds with the same host seen
44
+ // by other sources (e.g. Timing, where it also carries the hardware Device
45
+ // that links to it via inRealm).
46
+ const realm = {
47
+ '@type': 'Realm',
48
+ '@key': ['@type', 'source', 'handle'],
49
+ source: 'hostname',
50
+ handle: (this.config.hostname ?? hostname()).trim().split('.')[0].toLowerCase(),
51
+ };
52
+ return {
53
+ '@type': 'Person',
54
+ '@key': ['@type', 'source', 'handle', 'memberOf[*].handle'],
55
+ source: 'shell',
56
+ handle: this.config.username ?? userInfo().username,
57
+ memberOf: [realm],
58
+ sameAs: ['@me'],
59
+ };
60
+ }
61
+ buildCommand(data) {
62
+ return {
63
+ '@type': 'Command',
64
+ '@key': ['@type', 'source', 'body', 'action.timestamp'],
65
+ source: 'shell',
66
+ body: data.command,
67
+ };
68
+ }
69
+ }
@@ -0,0 +1,3 @@
1
+ export { ShellHistoryExtractor } from './connectors/ShellHistoryExtractor.js';
2
+ export { default as ShellHistoryTransformer } from './connectors/ShellHistoryTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { ShellHistoryExtractor } from './connectors/ShellHistoryExtractor.js';
2
+ export { default as ShellHistoryTransformer } from './connectors/ShellHistoryTransformer.js';
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@chronicle.app/shell",
3
+ "version": "0.1.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [],
17
+ "author": "Andrew Louis",
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "description": "Chronicle shell extraction plugin",
21
+ "devDependencies": {
22
+ "@chronicle.app/tsconfig": "0.1.0",
23
+ "@types/node": "^22.10.1",
24
+ "typescript": "^5.7.2"
25
+ },
26
+ "homepage": "https://chronicle.app",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/chronicle-app/chronicle.git",
30
+ "directory": "plugins/shell"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "registry": "https://registry.npmjs.org/"
35
+ },
36
+ "dependencies": {
37
+ "@chronicle.app/etl": "0.1.0",
38
+ "@chronicle.app/schema": "0.1.0",
39
+ "zod": "^3.24.1"
40
+ },
41
+ "engines": {
42
+ "node": ">=22.13.0"
43
+ },
44
+ "exports": {
45
+ ".": {
46
+ "types": "./dist/index.d.ts",
47
+ "import": "./dist/index.js"
48
+ }
49
+ },
50
+ "chronicle": {
51
+ "plugin": true
52
+ },
53
+ "oclif": {}
54
+ }